diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json b/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json index 41bb4d204c2a..6a4433f33d6f 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json @@ -361,6 +361,26 @@ } } }, + "get_goal": { + "emoji": "🎯", + "title": "Get Goal", + "detailKeys": [] + }, + "create_goal": { + "emoji": "🎯", + "title": "Create Goal", + "detailKeys": [ + "objective", + "token_budget" + ] + }, + "update_goal": { + "emoji": "🎯", + "title": "Update Goal", + "detailKeys": [ + "status" + ] + }, "update_plan": { "emoji": "πŸ—ΊοΈ", "title": "Update Plan", diff --git a/docs/cli/tui.md b/docs/cli/tui.md index efb6a8b54018..a4cdd59860fb 100644 --- a/docs/cli/tui.md +++ b/docs/cli/tui.md @@ -43,6 +43,7 @@ Notes: - Local mode uses the embedded agent runtime directly. Most local tools work, but Gateway-only features are unavailable. - Local mode adds `/auth [provider]` inside the TUI command surface. - Plugin approval gates still apply in local mode. Tools that require approval prompt for a decision in the terminal; nothing is silently auto-approved because the Gateway is not involved. +- Session [goals](/tools/goal) appear in the footer and can be managed with `/goal`. ## Examples @@ -87,3 +88,4 @@ rerun `openclaw config validate`. See [TUI](/web/tui) and [Config](/cli/config). - [CLI reference](/cli) - [TUI](/web/tui) +- [Goal](/tools/goal) diff --git a/docs/docs.json b/docs/docs.json index 8b8c992d128c..b2329fed890f 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1333,6 +1333,7 @@ "group": "Agent coordination", "pages": [ "tools/agent-send", + "tools/goal", "tools/steer", "tools/subagents", "tools/acp-agents", diff --git a/docs/tools/goal.md b/docs/tools/goal.md new file mode 100644 index 000000000000..246566949bdf --- /dev/null +++ b/docs/tools/goal.md @@ -0,0 +1,217 @@ +--- +doc-schema-version: 1 +summary: "Session goals: durable per-session objectives, /goal controls, model goal tools, token budgets, and TUI status" +read_when: + - You want OpenClaw to keep one objective visible across a long session + - You need to pause, resume, block, complete, or clear a session goal + - You want to understand the get_goal, create_goal, and update_goal tools + - You want to see how goals appear in the TUI +title: "Goal" +--- + +# Goal + +A **goal** is one durable objective attached to the current OpenClaw session. +It gives the agent and the operator a shared target for long-running work, +without turning that target into a background task, reminder, cron job, or +standing order. + +Goals are session state. They move with the session key, survive process +restarts, show up in `/goal`, are available to the model through the goal +tools, and appear in the TUI footer when the active session has one. + +## Quick start + +Set a goal: + +```text +/goal start get CI green for PR 87469 and push the fix +``` + +Check it: + +```text +/goal +``` + +Pause it when work is intentionally waiting: + +```text +/goal pause waiting for CI +``` + +Resume it: + +```text +/goal resume +``` + +Mark it complete: + +```text +/goal complete pushed and verified +``` + +Clear it: + +```text +/goal clear +``` + +## What goals are for + +Use a goal when a session has a concrete outcome that should remain visible +across many turns: + +- A PR closeout: fix, verify, autoreview, push, and open or update the PR. +- A debug run: reproduce the bug, identify the owning surface, patch, and prove + the fix. +- A docs pass: read the relevant docs, write the new page, cross-link it, and + verify the docs build. +- A maintenance task: inspect current state, make bounded changes, run the right + checks, and report what changed. + +A goal is not a task queue. Use [Task Flow](/automation/taskflow), +[tasks](/automation/tasks), [cron jobs](/automation/cron-jobs), or +[standing orders](/automation/standing-orders) when work should run detached, +repeat on a schedule, fan out into managed sub-work, or persist as a policy. + +## Command reference + +`/goal` without arguments prints the current goal summary: + +```text +Goal +Status: active +Objective: get CI green for PR 87469 and push the fix +Tokens used: 12k +Token budget: 12k/50k + +Commands: /goal pause, /goal complete, /goal clear +``` + +Commands: + +- `/goal` or `/goal status` shows the current goal. +- `/goal start ` creates a new goal for the current session. +- `/goal set ` and `/goal create ` are aliases for + `start`. +- `/goal pause [note]` pauses an active goal. +- `/goal resume [note]` resumes a paused, blocked, usage-limited, or + budget-limited goal. +- `/goal complete [note]` marks the goal achieved. +- `/goal done [note]` is an alias for `complete`. +- `/goal block [note]` marks the goal blocked. +- `/goal blocked [note]` is an alias for `block`. +- `/goal clear` removes the goal from the session. + +Only one goal can exist on a session at a time. Starting a second goal fails +until the current one is cleared. + +## Statuses + +Goals use a small status set: + +- `active`: the session is pursuing the goal. +- `paused`: the operator paused the goal; `/goal resume` makes it active again. +- `blocked`: the agent or operator reported a real blocker; `/goal resume` + makes it active again when new information or state is available. +- `budget_limited`: the configured token budget was reached; `/goal resume` + restarts pursuit from the same objective. +- `usage_limited`: reserved for usage-limit stop states; `/goal resume` + restarts pursuit when allowed. +- `complete`: the goal was achieved. Complete goals are terminal; use + `/goal clear` before starting another goal. + +`/new` and `/reset` clear the current session goal because they intentionally +start fresh session context. + +## Token budgets + +Goals can have an optional positive token budget. The budget is stored with the +goal and measured from the session's fresh token count at creation time. If the +current session only has stale or unknown token usage when the goal starts, +OpenClaw waits for the next fresh session token snapshot and uses that as the +baseline, so tokens spent before the goal existed are not charged to the goal. + +When token usage reaches the budget, the goal changes to `budget_limited`. This +does not delete the goal or erase the objective. It tells the operator and the +agent that the goal is no longer actively being pursued until it is resumed or +cleared. + +Token budgets are a session-goal guardrail, not a billing cap. Provider quota, +cost reporting, and context-window behavior still use the normal OpenClaw +usage and model controls. + +## Model tools + +OpenClaw exposes three core goal tools to agent harnesses: + +- `get_goal`: read the current session goal, including status, objective, token + usage, and token budget. +- `create_goal`: create a goal only when the user, system, or developer + instructions explicitly request one. It fails if the session already has a + goal. +- `update_goal`: mark the goal `complete` or `blocked`. + +The model cannot silently pause, resume, clear, or replace a goal. Those are +operator/session controls through `/goal` and reset commands. This keeps the +agent from quietly moving the target while preserving a clean path for the +agent to report achievement or a genuine blocker. + +The `update_goal` tool should mark a goal `complete` only when the objective is +actually achieved. It should mark a goal `blocked` only when the same blocking +condition has repeated and the agent cannot make meaningful progress without +new user input or an external-state change. + +## TUI + +The TUI keeps the active session's goal visible in the footer next to the +agent, session, model, run controls, and token counts. + +Footer examples: + +- `Pursuing goal (12k/50k)` for an active goal with a token budget. +- `Goal paused (/goal resume)` for a paused goal. +- `Goal blocked (/goal resume)` for a blocked goal. +- `Goal hit usage limits (/goal resume)` for a usage-limited goal. +- `Goal unmet (50k/50k)` for a budget-limited goal. +- `Goal achieved (42k)` for a completed goal. + +The footer is intentionally compact. Use `/goal` for the full objective, note, +token budget, and available commands. + +## Channel behavior + +The `/goal` command works in command-capable OpenClaw sessions, including the +TUI and chat surfaces that permit text commands. Goal state is attached to the +session key, not the transport. If two surfaces use the same session, they see +the same goal. + +Goal state is not a delivery directive. It does not force replies through a +channel, change queue behavior, approve tools, or schedule work. + +## Troubleshooting + +`Goal error: goal already exists` means the session already has a goal. Use +`/goal` to inspect it, `/goal complete` if it is done, or `/goal clear` before +starting a different objective. + +`Goal error: goal not found` means the session has no goal yet. Start one with +`/goal start `. + +`Goal error: goal is already complete` means the goal is terminal. Clear it +before starting or resuming another objective. + +If token usage looks like `0` or stale, the active session may not have a fresh +token snapshot yet. Usage refreshes as OpenClaw records session usage and +transcript-derived totals. + +## Related + +- [Slash commands](/tools/slash-commands) +- [TUI](/web/tui) +- [Session tool](/concepts/session-tool) +- [Compaction](/concepts/compaction) +- [Task Flow](/automation/taskflow) +- [Standing orders](/automation/standing-orders) diff --git a/docs/tools/index.md b/docs/tools/index.md index fc08df5ddeca..12e5ef45f388 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -78,18 +78,18 @@ The table lists representative tools so you can recognize the surface. It is not the full policy reference. For exact groups, defaults, and allow/deny semantics, use [Tools and custom providers](/gateway/config-tools). -| Category | Use when the agent needs to... | Representative tools | Read next | -| ----------------------- | ----------------------------------------------------------------------------- | -------------------------------------------------------------------- | ---------------------------------------------------------------------- | -| Runtime | Run commands, manage processes, or use provider-backed Python analysis | `exec`, `process`, `code_execution` | [Exec](/tools/exec), [Code execution](/tools/code-execution) | -| Files | Read and change workspace files | `read`, `write`, `edit`, `apply_patch` | [Apply patch](/tools/apply-patch) | -| Web | Search the web, search X posts, or fetch readable page content | `web_search`, `x_search`, `web_fetch` | [Web tools](/tools/web), [Web fetch](/tools/web-fetch) | -| Browser | Operate a browser session | `browser` | [Browser](/tools/browser) | -| Messaging and channels | Send replies or channel actions | `message` | [Agent send](/tools/agent-send) | -| Sessions and agents | Inspect sessions, delegate work, steer another run, or report status | `sessions_*`, `subagents`, `agents_list`, `session_status` | [Sub-agents](/tools/subagents), [Session tool](/concepts/session-tool) | -| Automation | Schedule work or respond to background events | `cron`, `heartbeat_respond` | [Automation](/automation) | -| Gateway and nodes | Inspect Gateway state or paired target devices | `gateway`, `nodes` | [Gateway configuration](/gateway/configuration), [Nodes](/nodes) | -| Media | Analyze, generate, or speak media | `image`, `image_generate`, `music_generate`, `video_generate`, `tts` | [Media overview](/tools/media-overview) | -| Large OpenClaw catalogs | Search and call many eligible tools without sending every schema to the model | `tool_search_code`, `tool_search`, `tool_describe` | [Tool Search](/tools/tool-search) | +| Category | Use when the agent needs to... | Representative tools | Read next | +| ----------------------- | ----------------------------------------------------------------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | +| Runtime | Run commands, manage processes, or use provider-backed Python analysis | `exec`, `process`, `code_execution` | [Exec](/tools/exec), [Code execution](/tools/code-execution) | +| Files | Read and change workspace files | `read`, `write`, `edit`, `apply_patch` | [Apply patch](/tools/apply-patch) | +| Web | Search the web, search X posts, or fetch readable page content | `web_search`, `x_search`, `web_fetch` | [Web tools](/tools/web), [Web fetch](/tools/web-fetch) | +| Browser | Operate a browser session | `browser` | [Browser](/tools/browser) | +| Messaging and channels | Send replies or channel actions | `message` | [Agent send](/tools/agent-send) | +| Sessions and agents | Inspect sessions, delegate work, steer another run, or report status | `sessions_*`, `subagents`, `agents_list`, `session_status`, `goal` | [Goal](/tools/goal), [Sub-agents](/tools/subagents), [Session tool](/concepts/session-tool) | +| Automation | Schedule work or respond to background events | `cron`, `heartbeat_respond` | [Automation](/automation) | +| Gateway and nodes | Inspect Gateway state or paired target devices | `gateway`, `nodes` | [Gateway configuration](/gateway/configuration), [Nodes](/nodes) | +| Media | Analyze, generate, or speak media | `image`, `image_generate`, `music_generate`, `video_generate`, `tts` | [Media overview](/tools/media-overview) | +| Large OpenClaw catalogs | Search and call many eligible tools without sending every schema to the model | `tool_search_code`, `tool_search`, `tool_describe` | [Tool Search](/tools/tool-search) | Tool Search is an experimental OpenClaw agent surface. Codex harness runs use diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index 83099c30c78e..21736a006bdd 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -153,6 +153,7 @@ Current source-of-truth: - `/commands` shows the generated command catalog. - `/tools [compact|verbose]` shows what the current agent can use right now. - `/status` shows execution/runtime status, Gateway and system uptime, plus provider usage/quota when available. + - `/goal [status] | /goal start | /goal pause|resume|complete|block|clear` manages the current session's durable [goal](/tools/goal). - `/diagnostics [note]` is the owner-only support-report flow for Gateway bugs and Codex harness runs. It asks for explicit exec approval every time before running `openclaw gateway diagnostics export --json`; do not approve diagnostics with an allow-all rule. After approval, it sends a pasteable report with the local bundle path, manifest summary, privacy notes, and relevant session ids. In group chats, the approval prompt and report go to the owner privately. When the active session uses the OpenAI Codex harness, the same approval also sends relevant Codex feedback to OpenAI servers and the completed reply lists the OpenClaw session ids, Codex thread ids, and `codex resume ` commands. See [Diagnostics Export](/gateway/diagnostics). - `/crestodian ` runs the Crestodian setup and repair helper from an owner DM. - `/tasks` lists active/recent background tasks for the current session. diff --git a/docs/web/tui.md b/docs/web/tui.md index 5a3df947f3d4..67c22de6e809 100644 --- a/docs/web/tui.md +++ b/docs/web/tui.md @@ -54,7 +54,7 @@ Notes: - Header: connection URL, current agent, current session. - Chat log: user messages, assistant replies, system notices, tool cards. - Status line: connection/run state (connecting, running, streaming, idle, error). -- Footer: connection state + agent + session + model + think/fast/verbose/trace/reasoning + token counts + deliver. +- Footer: connection state + agent + session + model + goal state + think/fast/verbose/trace/reasoning + token counts + deliver. - Input: text editor with autocomplete. ## Mental model: agents + sessions @@ -68,6 +68,9 @@ Notes: - `per-sender` (default): each agent has many sessions. - `global`: the TUI always uses the `global` session (the picker may be empty). - The current agent + session are always visible in the footer. +- If the session has a [goal](/tools/goal), the footer shows its compact state + such as `Pursuing goal`, `Goal paused (/goal resume)`, or + `Goal achieved`. - When started without `--session`, gateway-mode TUI resumes the last selected session for the same gateway, agent, and session scope if that session still exists. Passing `--session`, `/session`, `/new`, or `/reset` remains explicit. ## Sending + delivery @@ -116,6 +119,7 @@ Session controls: - `/trace ` - `/reasoning ` - `/usage ` +- `/goal [status] | /goal start | /goal pause|resume|complete|block|clear` - `/elevated ` (alias: `/elev`) - `/activation ` - `/deliver ` diff --git a/extensions/codex/src/app-server/transcript-mirror.ts b/extensions/codex/src/app-server/transcript-mirror.ts index af5dba0f56d1..e18f0e27d22c 100644 --- a/extensions/codex/src/app-server/transcript-mirror.ts +++ b/extensions/codex/src/app-server/transcript-mirror.ts @@ -358,6 +358,7 @@ export async function mirrorCodexAppServerTranscript(params: { emitSessionTranscriptUpdate({ sessionFile: params.sessionFile, ...(params.sessionKey ? { sessionKey: params.sessionKey } : {}), + ...(params.agentId ? { agentId: params.agentId } : {}), message: update.message, messageId: update.messageId, messageSeq: update.messageSeq, diff --git a/extensions/telegram/src/bot-message-dispatch.ts b/extensions/telegram/src/bot-message-dispatch.ts index 093a15310686..127f5cfb4b74 100644 --- a/extensions/telegram/src/bot-message-dispatch.ts +++ b/extensions/telegram/src/bot-message-dispatch.ts @@ -383,6 +383,7 @@ async function mirrorTelegramAssistantReplyToTranscript(params: { emitSessionTranscriptUpdate({ sessionFile, sessionKey: params.sessionKey, + agentId: params.route.agentId, message: appendedMessage, messageId, }); diff --git a/packages/gateway-protocol/src/index.test.ts b/packages/gateway-protocol/src/index.test.ts index 8e3d58913c07..2e293b0711b7 100644 --- a/packages/gateway-protocol/src/index.test.ts +++ b/packages/gateway-protocol/src/index.test.ts @@ -3,6 +3,9 @@ import { TALK_TEST_PROVIDER_ID } from "../../../src/test-utils/talk-test-provide import * as protocol from "./index.js"; import { formatValidationErrors, + validateChatAbortParams, + validateChatHistoryParams, + validateChatSendParams, validateChatEvent, validateCommandsListParams, validateConnectParams, @@ -70,6 +73,37 @@ describe("lazy protocol validators", () => { expect(validateConnectParams.errors).toBeNull(); }); + it("accepts selected-agent scope on chat send, history, and abort params", () => { + expect( + validateChatHistoryParams({ + sessionKey: "global", + agentId: "work", + limit: 50, + }), + ).toBe(true); + expect( + validateChatSendParams({ + sessionKey: "global", + agentId: "work", + message: "hello", + idempotencyKey: "run-global-work", + }), + ).toBe(true); + expect( + validateChatAbortParams({ + sessionKey: "global", + agentId: "work", + runId: "run-global-work", + }), + ).toBe(true); + expect( + protocol.validateSessionsCompactParams({ + key: "global", + agentId: "work", + }), + ).toBe(true); + }); + it("can still compile every exported protocol validator", () => { const failures: string[] = []; const validators: Array<[string, ProtocolValidator]> = []; @@ -577,6 +611,19 @@ describe("validateChatEvent", () => { ).toBe(true); }); + it("accepts selected-agent chat events", () => { + expect( + validateChatEvent({ + runId: "run-chat", + sessionKey: "global", + agentId: "work", + seq: 1, + state: "delta", + deltaText: "hello", + }), + ).toBe(true); + }); + it("rejects v3-style chat deltas without deltaText", () => { expect( validateChatEvent({ diff --git a/packages/gateway-protocol/src/schema/logs-chat.ts b/packages/gateway-protocol/src/schema/logs-chat.ts index a4d568eafbfc..db4c04595792 100644 --- a/packages/gateway-protocol/src/schema/logs-chat.ts +++ b/packages/gateway-protocol/src/schema/logs-chat.ts @@ -26,6 +26,7 @@ export const LogsTailResultSchema = Type.Object( export const ChatHistoryParamsSchema = Type.Object( { sessionKey: NonEmptyString, + agentId: Type.Optional(NonEmptyString), limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 1000 })), maxChars: Type.Optional(Type.Integer({ minimum: 1, maximum: 500_000 })), }, @@ -35,6 +36,7 @@ export const ChatHistoryParamsSchema = Type.Object( export const ChatSendParamsSchema = Type.Object( { sessionKey: ChatSendSessionKeyString, + agentId: Type.Optional(NonEmptyString), sessionId: Type.Optional(NonEmptyString), message: Type.String(), thinking: Type.Optional(Type.String()), @@ -56,6 +58,7 @@ export const ChatSendParamsSchema = Type.Object( export const ChatAbortParamsSchema = Type.Object( { sessionKey: NonEmptyString, + agentId: Type.Optional(NonEmptyString), runId: Type.Optional(NonEmptyString), }, { additionalProperties: false }, @@ -64,6 +67,7 @@ export const ChatAbortParamsSchema = Type.Object( export const ChatInjectParamsSchema = Type.Object( { sessionKey: NonEmptyString, + agentId: Type.Optional(NonEmptyString), message: NonEmptyString, label: Type.Optional(Type.String({ maxLength: 100 })), }, @@ -73,6 +77,7 @@ export const ChatInjectParamsSchema = Type.Object( const ChatEventBaseSchema = { runId: NonEmptyString, sessionKey: NonEmptyString, + agentId: Type.Optional(NonEmptyString), spawnedBy: Type.Optional(NonEmptyString), seq: Type.Integer({ minimum: 0 }), }; diff --git a/packages/gateway-protocol/src/schema/sessions.ts b/packages/gateway-protocol/src/schema/sessions.ts index 0f94cb797d22..1ee640782d68 100644 --- a/packages/gateway-protocol/src/schema/sessions.ts +++ b/packages/gateway-protocol/src/schema/sessions.ts @@ -15,6 +15,7 @@ export const SessionOperationEventSchema = Type.Object( operation: Type.Literal("compact"), phase: Type.Union([Type.Literal("start"), Type.Literal("end")]), sessionKey: NonEmptyString, + agentId: Type.Optional(NonEmptyString), ts: Type.Integer({ minimum: 0 }), completed: Type.Optional(Type.Boolean()), reason: Type.Optional(Type.String()), @@ -143,6 +144,7 @@ export const SessionsCreateParamsSchema = Type.Object( export const SessionsSendParamsSchema = Type.Object( { key: NonEmptyString, + agentId: Type.Optional(NonEmptyString), message: Type.String(), thinking: Type.Optional(Type.String()), attachments: Type.Optional(Type.Array(Type.Unknown())), @@ -155,6 +157,7 @@ export const SessionsSendParamsSchema = Type.Object( export const SessionsMessagesSubscribeParamsSchema = Type.Object( { key: NonEmptyString, + agentId: Type.Optional(NonEmptyString), }, { additionalProperties: false }, ); @@ -162,6 +165,7 @@ export const SessionsMessagesSubscribeParamsSchema = Type.Object( export const SessionsMessagesUnsubscribeParamsSchema = Type.Object( { key: NonEmptyString, + agentId: Type.Optional(NonEmptyString), }, { additionalProperties: false }, ); @@ -178,6 +182,7 @@ export const SessionsAbortParamsSchema = Type.Object( export const SessionsPatchParamsSchema = Type.Object( { key: NonEmptyString, + agentId: Type.Optional(NonEmptyString), label: Type.Optional(Type.Union([SessionLabelString, Type.Null()])), thinkingLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), fastMode: Type.Optional(Type.Union([Type.Boolean(), Type.Null()])), @@ -245,6 +250,7 @@ export const SessionsPluginPatchResultSchema = Type.Object( export const SessionsResetParamsSchema = Type.Object( { key: NonEmptyString, + agentId: Type.Optional(NonEmptyString), reason: Type.Optional(Type.Union([Type.Literal("new"), Type.Literal("reset")])), }, { additionalProperties: false }, @@ -253,6 +259,7 @@ export const SessionsResetParamsSchema = Type.Object( export const SessionsDeleteParamsSchema = Type.Object( { key: NonEmptyString, + agentId: Type.Optional(NonEmptyString), deleteTranscript: Type.Optional(Type.Boolean()), // Internal control: when false, still unbind thread bindings but skip hook emission. emitLifecycleHooks: Type.Optional(Type.Boolean()), @@ -263,6 +270,7 @@ export const SessionsDeleteParamsSchema = Type.Object( export const SessionsCompactParamsSchema = Type.Object( { key: NonEmptyString, + agentId: Type.Optional(NonEmptyString), maxLines: Type.Optional(Type.Integer({ minimum: 1 })), }, { additionalProperties: false }, @@ -271,6 +279,7 @@ export const SessionsCompactParamsSchema = Type.Object( export const SessionsCompactionListParamsSchema = Type.Object( { key: NonEmptyString, + agentId: Type.Optional(NonEmptyString), }, { additionalProperties: false }, ); @@ -278,6 +287,7 @@ export const SessionsCompactionListParamsSchema = Type.Object( export const SessionsCompactionGetParamsSchema = Type.Object( { key: NonEmptyString, + agentId: Type.Optional(NonEmptyString), checkpointId: NonEmptyString, }, { additionalProperties: false }, @@ -286,6 +296,7 @@ export const SessionsCompactionGetParamsSchema = Type.Object( export const SessionsCompactionBranchParamsSchema = Type.Object( { key: NonEmptyString, + agentId: Type.Optional(NonEmptyString), checkpointId: NonEmptyString, }, { additionalProperties: false }, @@ -294,6 +305,7 @@ export const SessionsCompactionBranchParamsSchema = Type.Object( export const SessionsCompactionRestoreParamsSchema = Type.Object( { key: NonEmptyString, + agentId: Type.Optional(NonEmptyString), checkpointId: NonEmptyString, }, { additionalProperties: false }, diff --git a/src/agents/agent-command.live-model-switch.test.ts b/src/agents/agent-command.live-model-switch.test.ts index 77769d706a85..208447b6537c 100644 --- a/src/agents/agent-command.live-model-switch.test.ts +++ b/src/agents/agent-command.live-model-switch.test.ts @@ -56,6 +56,7 @@ const state = vi.hoisted(() => ({ sessionEntryMock: undefined as unknown, sessionStoreMock: undefined as unknown, storePathMock: undefined as string | undefined, + resolvedSessionKeyMock: undefined as string | undefined, })); vi.mock("./model-fallback.js", () => ({ @@ -119,7 +120,7 @@ vi.mock("./command/session-store.runtime.js", () => ({ vi.mock("./command/session.js", () => ({ resolveSession: () => ({ sessionId: "session-1", - sessionKey: "agent:main:main", + sessionKey: state.resolvedSessionKeyMock ?? "agent:main:main", sessionEntry: state.sessionEntryMock ?? { sessionId: "session-1", updatedAt: Date.now(), @@ -668,9 +669,12 @@ vi.mock("../acp/control-plane/manager.js", () => ({ })); let agentCommand: typeof import("./agent-command.js").agentCommand; +let agentCommandTesting: typeof import("./agent-command.js").testing; beforeAll(async () => { - agentCommand ??= (await import("./agent-command.js")).agentCommand; + const mod = await import("./agent-command.js"); + agentCommand ??= mod.agentCommand; + agentCommandTesting ??= mod.testing; }); type FallbackRunnerParams = { @@ -808,6 +812,7 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => { state.resolveAcpDispatchPolicyErrorMock.mockReturnValue(null); state.resolveAcpExplicitTurnPolicyErrorMock.mockReturnValue(null); state.runtimeConfigMock = undefined; + delete (state.defaultRuntimeConfig.agents as { list?: unknown }).list; state.isThinkingLevelSupportedMock.mockReturnValue(true); state.resolveThinkingDefaultMock.mockReturnValue("low"); state.resolveAgentSkillsFilterMock.mockReturnValue(undefined); @@ -845,6 +850,7 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => { state.sessionEntryMock = undefined; state.sessionStoreMock = undefined; state.storePathMock = undefined; + state.resolvedSessionKeyMock = undefined; state.persistSessionEntryMock.mockImplementation(async (...args: unknown[]) => { const params = args[0] as { sessionStore?: Record; @@ -1041,6 +1047,23 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => { }); }); + it("keeps explicit-agent global keys literal before command routing", () => { + expect( + agentCommandTesting.resolveExplicitAgentCommandSessionKey({ + rawExplicitSessionKey: "global", + agentIdOverride: "work", + cfg: {}, + }), + ).toBe("global"); + expect( + agentCommandTesting.resolveExplicitAgentCommandSessionKey({ + rawExplicitSessionKey: "main", + agentIdOverride: "work", + cfg: {}, + }), + ).toBe("agent:work:main"); + }); + it("persists explicit overrides even when ingress skips the initial touch", async () => { setupSingleAttemptFallback(); state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("openai", "gpt-5.4")); diff --git a/src/agents/agent-command.ts b/src/agents/agent-command.ts index 979044ac7e36..d4efbf87e2f9 100644 --- a/src/agents/agent-command.ts +++ b/src/agents/agent-command.ts @@ -12,6 +12,7 @@ import { formatCliCommand } from "../cli/command-format.js"; import type { CliDeps } from "../cli/deps.types.js"; import { getRuntimeConfig } from "../config/io.js"; import type { SessionEntry } from "../config/sessions/types.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; import { withLocalGatewayRequestScope } from "../gateway/local-request-context.js"; import { clearAgentRunContext, @@ -337,6 +338,24 @@ function createAgentCommandSessionWorkingCopy(params: { return result; } +function resolveExplicitAgentCommandSessionKey(params: { + rawExplicitSessionKey?: string; + agentIdOverride?: string; + shouldScopeDefaultAgentKey?: boolean; + cfg: OpenClawConfig; +}): string | undefined { + if (isUnscopedSessionKeySentinel(params.rawExplicitSessionKey)) { + return params.rawExplicitSessionKey; + } + return scopeLegacySessionKeyToAgent({ + agentId: + params.agentIdOverride ?? + (params.shouldScopeDefaultAgentKey ? resolveDefaultAgentId(params.cfg) : undefined), + sessionKey: params.rawExplicitSessionKey, + mainKey: params.cfg.session?.mainKey, + }); +} + async function prepareAgentCommandExecution(opts: AgentCommandOpts, runtime: RuntimeEnv) { const isRawModelRun = opts.modelRun === true || opts.promptMode === "none"; const message = opts.message ?? ""; @@ -370,16 +389,17 @@ async function prepareAgentCommandExecution(opts: AgentCommandOpts, runtime: Run ); } } - const shouldScopeDefaultAgentKey = + const shouldScopeDefaultAgentKey = Boolean( rawExplicitSessionKey && !agentIdOverride && classifySessionKeyShape(rawExplicitSessionKey) === "legacy_or_alias" && - !isUnscopedSessionKeySentinel(rawExplicitSessionKey); - const explicitSessionKey = scopeLegacySessionKeyToAgent({ - agentId: - agentIdOverride ?? (shouldScopeDefaultAgentKey ? resolveDefaultAgentId(cfg) : undefined), - sessionKey: rawExplicitSessionKey, - mainKey: cfg.session?.mainKey, + !isUnscopedSessionKeySentinel(rawExplicitSessionKey), + ); + const explicitSessionKey = resolveExplicitAgentCommandSessionKey({ + rawExplicitSessionKey, + agentIdOverride, + shouldScopeDefaultAgentKey, + cfg, }); if (explicitSessionKey && classifySessionKeyShape(explicitSessionKey) === "malformed_agent") { throw new Error( @@ -1839,6 +1859,7 @@ export async function agentCommandFromIngress( export const testing = { resolveAgentRuntimeConfig, prepareAgentCommandExecution, + resolveExplicitAgentCommandSessionKey, }; /** @deprecated Use `testing`. */ diff --git a/src/agents/agent-scope.ts b/src/agents/agent-scope.ts index be7c9e075fb4..bc0a161021df 100644 --- a/src/agents/agent-scope.ts +++ b/src/agents/agent-scope.ts @@ -299,6 +299,7 @@ export function resolveSessionAgentIds(params: { export function resolveSessionAgentId(params: { sessionKey?: string; config?: OpenClawConfig; + agentId?: string; }): string { return resolveSessionAgentIds(params).sessionAgentId; } diff --git a/src/agents/command/attempt-execution.ts b/src/agents/command/attempt-execution.ts index 421defb2f4f7..0a771114a7dd 100644 --- a/src/agents/command/attempt-execution.ts +++ b/src/agents/command/attempt-execution.ts @@ -292,7 +292,11 @@ async function persistTextTurnTranscript( await lock.release(); } - emitSessionTranscriptUpdate({ sessionFile, sessionKey: params.sessionKey }); + emitSessionTranscriptUpdate({ + sessionFile, + sessionKey: params.sessionKey, + agentId: params.sessionAgentId, + }); return sessionEntry; } diff --git a/src/agents/embedded-agent-runner/compact.hooks.test.ts b/src/agents/embedded-agent-runner/compact.hooks.test.ts index 4d6328c9cdaa..f1e6b80a0b10 100644 --- a/src/agents/embedded-agent-runner/compact.hooks.test.ts +++ b/src/agents/embedded-agent-runner/compact.hooks.test.ts @@ -1102,6 +1102,7 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { expect(result.ok).toBe(true); expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith({ + agentId: "main", sessionFile: "/tmp/rotated-session.jsonl", sessionKey: TEST_SESSION_KEY, }); @@ -1576,6 +1577,7 @@ describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => { expect(result.ok).toBe(true); expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith({ + agentId: "main", sessionFile: TEST_SESSION_FILE, sessionKey: TEST_SESSION_KEY, }); diff --git a/src/agents/embedded-agent-runner/compact.queued.ts b/src/agents/embedded-agent-runner/compact.queued.ts index cec536fd5641..339b42edf3d1 100644 --- a/src/agents/embedded-agent-runner/compact.queued.ts +++ b/src/agents/embedded-agent-runner/compact.queued.ts @@ -161,6 +161,7 @@ export async function compactEmbeddedAgentSession( const agentIds = resolveSessionAgentIds({ sessionKey: params.sessionKey, config: params.config, + agentId: params.agentId, }); const agentDir = params.agentDir ?? resolveAgentDir(params.config ?? {}, agentIds.sessionAgentId); const resolvedWorkspaceDir = resolveUserPath(params.workspaceDir); @@ -271,6 +272,7 @@ export async function compactEmbeddedAgentSession( const { sessionAgentId } = resolveSessionAgentIds({ sessionKey: params.sessionKey, config: params.config, + agentId: params.agentId, }); const resolvedMessageProvider = params.messageChannel ?? params.messageProvider; const hookCtx = { @@ -406,6 +408,7 @@ export async function compactEmbeddedAgentSession( await runPostCompactionSideEffects({ config: params.config, sessionKey: params.sessionKey, + agentId: sessionAgentId, sessionFile: postCompactionSessionFile, }); } @@ -474,6 +477,7 @@ function buildCompactionContextEngineRuntimeContext(params: { const { sessionAgentId } = resolveSessionAgentIds({ sessionKey: params.params.sessionKey, config: params.params.config, + agentId: params.params.agentId, }); return { ...params.params, diff --git a/src/agents/embedded-agent-runner/compact.ts b/src/agents/embedded-agent-runner/compact.ts index 7f4e25989b82..b213d0fd28c6 100644 --- a/src/agents/embedded-agent-runner/compact.ts +++ b/src/agents/embedded-agent-runner/compact.ts @@ -430,6 +430,7 @@ export async function compactEmbeddedAgentSessionDirect( const fallbackAgentId = resolveSessionAgentIds({ sessionKey: params.sandboxSessionKey ?? params.sessionKey, config: params.config, + agentId: params.agentId, }).sessionAgentId; const fallbackSessionKey = params.sandboxSessionKey ?? params.sessionKey ?? params.sessionId; try { @@ -533,6 +534,7 @@ async function compactEmbeddedAgentSessionDirectOnce( const earlyAgentIds = resolveSessionAgentIds({ sessionKey: params.sessionKey, config: params.config, + agentId: params.agentId, }); const agentDir = params.agentDir ?? resolveAgentDir(params.config ?? {}, earlyAgentIds.sessionAgentId); @@ -628,6 +630,7 @@ async function compactEmbeddedAgentSessionDirectOnce( const { sessionAgentId: effectiveSkillAgentId } = resolveSessionAgentIds({ sessionKey: params.sessionKey, config: params.config, + agentId: params.agentId, }); let restoreSkillEnv: (() => void) | undefined; @@ -873,6 +876,7 @@ async function compactEmbeddedAgentSessionDirectOnce( const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({ sessionKey: params.sessionKey, config: params.config, + agentId: params.agentId, }); // Resolve channel-specific message actions for system prompt const channelActions = runtimeChannel @@ -1371,6 +1375,7 @@ async function compactEmbeddedAgentSessionDirectOnce( await runPostCompactionSideEffects({ config: params.config, sessionKey: params.sessionKey, + agentId: sessionAgentId, sessionFile: activeSessionFile, }); if (params.config && params.sessionKey && checkpointSnapshot) { diff --git a/src/agents/embedded-agent-runner/compact.types.ts b/src/agents/embedded-agent-runner/compact.types.ts index 242472cf874e..eb6df209b026 100644 --- a/src/agents/embedded-agent-runner/compact.types.ts +++ b/src/agents/embedded-agent-runner/compact.types.ts @@ -11,6 +11,8 @@ export type CompactEmbeddedAgentSessionParams = { sessionId: string; runId?: string; sessionKey?: string; + /** Caller-resolved owner agent for global session aliases. */ + agentId?: string; /** Session key used only for runtime policy/sandbox resolution. Defaults to sessionKey. */ sandboxSessionKey?: string; messageChannel?: string; diff --git a/src/agents/embedded-agent-runner/compaction-hooks.ts b/src/agents/embedded-agent-runner/compaction-hooks.ts index 25ac76cdbbe4..949f819c0715 100644 --- a/src/agents/embedded-agent-runner/compaction-hooks.ts +++ b/src/agents/embedded-agent-runner/compaction-hooks.ts @@ -20,6 +20,7 @@ function resolvePostCompactionIndexSyncMode(config?: OpenClawConfig): "off" | "a async function runPostCompactionSessionMemorySync(params: { config?: OpenClawConfig; sessionKey?: string; + agentId?: string; sessionFile: string; }): Promise { if (!params.config) { @@ -33,6 +34,7 @@ async function runPostCompactionSessionMemorySync(params: { const agentId = resolveSessionAgentId({ sessionKey: params.sessionKey, config: params.config, + agentId: params.agentId, }); const resolvedMemory = resolveMemorySearchConfig(params.config, agentId); if (!resolvedMemory || !resolvedMemory.sources.includes("sessions")) { @@ -60,6 +62,7 @@ async function runPostCompactionSessionMemorySync(params: { function syncPostCompactionSessionMemory(params: { config?: OpenClawConfig; sessionKey?: string; + agentId?: string; sessionFile: string; mode: "off" | "async" | "await"; }): Promise { @@ -70,6 +73,7 @@ function syncPostCompactionSessionMemory(params: { const syncTask = runPostCompactionSessionMemorySync({ config: params.config, sessionKey: params.sessionKey, + agentId: params.agentId, sessionFile: params.sessionFile, }); if (params.mode === "await") { @@ -82,16 +86,22 @@ function syncPostCompactionSessionMemory(params: { export async function runPostCompactionSideEffects(params: { config?: OpenClawConfig; sessionKey?: string; + agentId?: string; sessionFile: string; }): Promise { const sessionFile = params.sessionFile.trim(); if (!sessionFile) { return; } - emitSessionTranscriptUpdate({ sessionFile, sessionKey: params.sessionKey }); + emitSessionTranscriptUpdate({ + sessionFile, + sessionKey: params.sessionKey, + ...(params.agentId ? { agentId: params.agentId } : {}), + }); await syncPostCompactionSessionMemory({ config: params.config, sessionKey: params.sessionKey, + agentId: params.agentId, sessionFile, mode: resolvePostCompactionIndexSyncMode(params.config), }); diff --git a/src/agents/embedded-agent-runner/context-engine-maintenance.ts b/src/agents/embedded-agent-runner/context-engine-maintenance.ts index 1724f0020171..e3d5b992ea96 100644 --- a/src/agents/embedded-agent-runner/context-engine-maintenance.ts +++ b/src/agents/embedded-agent-runner/context-engine-maintenance.ts @@ -335,6 +335,7 @@ export function buildContextEngineMaintenanceRuntimeContext(params: { sessionFile: params.sessionFile, sessionId: params.sessionId, sessionKey: params.sessionKey, + agentId: params.agentId, config: params.config, request, }); diff --git a/src/agents/embedded-agent-runner/run.ts b/src/agents/embedded-agent-runner/run.ts index bbb3ad5de5d9..158480db7428 100644 --- a/src/agents/embedded-agent-runner/run.ts +++ b/src/agents/embedded-agent-runner/run.ts @@ -1981,6 +1981,7 @@ export async function runEmbeddedAgent( await runPostCompactionSideEffects({ config: params.config, sessionKey: params.sessionKey, + agentId: sessionAgentId, sessionFile: activeSessionFile, }); } @@ -2210,6 +2211,7 @@ export async function runEmbeddedAgent( }), sessionId: activeSessionId, sessionKey: params.sessionKey, + agentId: sessionAgentId, config: params.config, }); if (truncResult.truncated) { @@ -2272,6 +2274,7 @@ export async function runEmbeddedAgent( maxCharsOverride: toolResultMaxChars, sessionId: activeSessionId, sessionKey: params.sessionKey, + agentId: sessionAgentId, config: params.config, }); if (truncResult.truncated) { diff --git a/src/agents/embedded-agent-runner/run/attempt-tool-construction-plan.ts b/src/agents/embedded-agent-runner/run/attempt-tool-construction-plan.ts index 262efa53bac6..ca8f76f05962 100644 --- a/src/agents/embedded-agent-runner/run/attempt-tool-construction-plan.ts +++ b/src/agents/embedded-agent-runner/run/attempt-tool-construction-plan.ts @@ -21,6 +21,7 @@ const OPENCLAW_TOOL_FACTORY_NAMES = new Set([ "canvas", "cron", "gateway", + "get_goal", "heartbeat_respond", "heartbeat_response", "image", @@ -35,8 +36,10 @@ const OPENCLAW_TOOL_FACTORY_NAMES = new Set([ "sessions_send", "sessions_spawn", "sessions_yield", + "create_goal", "subagents", "tts", + "update_goal", "update_plan", "video_generate", "web_fetch", diff --git a/src/agents/embedded-agent-runner/run/attempt.ts b/src/agents/embedded-agent-runner/run/attempt.ts index 6a88b94fccb7..fba973073be9 100644 --- a/src/agents/embedded-agent-runner/run/attempt.ts +++ b/src/agents/embedded-agent-runner/run/attempt.ts @@ -3248,6 +3248,7 @@ export async function runEmbeddedAttempt( sessionFile: params.sessionFile, sessionId: params.sessionId, sessionKey: params.sessionKey, + agentId: sessionAgentId, }); if (truncationResult.truncated) { preflightRecovery = { @@ -3910,6 +3911,7 @@ export async function runEmbeddedAttempt( sessionFile: params.sessionFile, sessionId: params.sessionId, sessionKey: params.sessionKey, + agentId: sessionAgentId, }); if (truncationResult.truncated) { preflightRecovery = { diff --git a/src/agents/embedded-agent-runner/tool-result-truncation.ts b/src/agents/embedded-agent-runner/tool-result-truncation.ts index 3af04ab05fd7..742c842dbbe1 100644 --- a/src/agents/embedded-agent-runner/tool-result-truncation.ts +++ b/src/agents/embedded-agent-runner/tool-result-truncation.ts @@ -668,6 +668,7 @@ function truncateOversizedToolResultsInExistingSessionManager(params: { sessionFile?: string; sessionId?: string; sessionKey?: string; + agentId?: string; }): { truncated: boolean; truncatedCount: number; reason?: string } { const { sessionManager, contextWindowTokens } = params; const maxChars = Math.max( @@ -706,6 +707,7 @@ function truncateOversizedToolResultsInExistingSessionManager(params: { emitSessionTranscriptUpdate({ sessionFile: params.sessionFile, sessionKey: params.sessionKey, + ...(params.agentId ? { agentId: params.agentId } : {}), }); } @@ -731,6 +733,7 @@ async function truncateOversizedToolResultsInTranscriptState(params: { aggregateMaxCharsOverride?: number; sessionId?: string; sessionKey?: string; + agentId?: string; config?: SessionWriteLockAcquireTimeoutConfig; }): Promise<{ truncated: boolean; truncatedCount: number; reason?: string }> { const { state, contextWindowTokens } = params; @@ -775,6 +778,7 @@ async function truncateOversizedToolResultsInTranscriptState(params: { emitSessionTranscriptUpdate({ sessionFile: params.sessionFile, sessionKey: params.sessionKey, + ...(params.agentId ? { agentId: params.agentId } : {}), }); } @@ -800,6 +804,7 @@ export function truncateOversizedToolResultsInSessionManager(params: { sessionFile?: string; sessionId?: string; sessionKey?: string; + agentId?: string; }): { truncated: boolean; truncatedCount: number; reason?: string } { try { return truncateOversizedToolResultsInExistingSessionManager(params); @@ -817,6 +822,7 @@ export async function truncateOversizedToolResultsInSession(params: { aggregateMaxCharsOverride?: number; sessionId?: string; sessionKey?: string; + agentId?: string; config?: SessionWriteLockAcquireTimeoutConfig; }): Promise<{ truncated: boolean; truncatedCount: number; reason?: string }> { const { sessionFile, contextWindowTokens } = params; diff --git a/src/agents/embedded-agent-runner/transcript-rewrite.ts b/src/agents/embedded-agent-runner/transcript-rewrite.ts index 408ad3e36877..7bee0fec2261 100644 --- a/src/agents/embedded-agent-runner/transcript-rewrite.ts +++ b/src/agents/embedded-agent-runner/transcript-rewrite.ts @@ -376,6 +376,7 @@ export async function rewriteTranscriptEntriesInSessionFile(params: { sessionFile: string; sessionId?: string; sessionKey?: string; + agentId?: string; request: TranscriptRewriteRequest; config?: SessionWriteLockAcquireTimeoutConfig; }): Promise { @@ -402,6 +403,7 @@ export async function rewriteTranscriptEntriesInSessionFile(params: { emitSessionTranscriptUpdate({ sessionFile: params.sessionFile, sessionKey: params.sessionKey, + ...(params.agentId ? { agentId: params.agentId } : {}), }); log.info( `[transcript-rewrite] rewrote ${result.rewrittenEntries} entr` + diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index 52023fa7f316..031028d99fe6 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -38,6 +38,11 @@ import type { AnyAgentTool } from "./tools/common.js"; import { createCronTool } from "./tools/cron-tool.js"; import { createEmbeddedCallGateway } from "./tools/embedded-gateway-stub.js"; import { createGatewayTool } from "./tools/gateway-tool.js"; +import { + createCreateGoalTool, + createGetGoalTool, + createUpdateGoalTool, +} from "./tools/goal-tools.js"; import { createHeartbeatResponseTool } from "./tools/heartbeat-response-tool.js"; import { createImageGenerateTool } from "./tools/image-generate-tool.js"; import { createImageTool } from "./tools/image-tool.js"; @@ -418,6 +423,24 @@ export function createOpenClawTools( agentSessionKey: options?.agentSessionKey, requesterAgentIdOverride: options?.requesterAgentIdOverride, }), + createGetGoalTool({ + agentSessionKey: options?.agentSessionKey, + runSessionKey: options?.runSessionKey, + sessionAgentId, + config: resolvedConfig, + }), + createCreateGoalTool({ + agentSessionKey: options?.agentSessionKey, + runSessionKey: options?.runSessionKey, + sessionAgentId, + config: resolvedConfig, + }), + createUpdateGoalTool({ + agentSessionKey: options?.agentSessionKey, + runSessionKey: options?.runSessionKey, + sessionAgentId, + config: resolvedConfig, + }), ...(includeUpdatePlanTool ? [createUpdatePlanTool()] : []), createSessionsListTool({ agentSessionKey: options?.agentSessionKey, diff --git a/src/agents/session-tool-result-guard-wrapper.ts b/src/agents/session-tool-result-guard-wrapper.ts index e6938627a734..1f4aef412b18 100644 --- a/src/agents/session-tool-result-guard-wrapper.ts +++ b/src/agents/session-tool-result-guard-wrapper.ts @@ -104,6 +104,7 @@ export function guardSessionManager( const guard = installSessionToolResultGuard(sessionManager, { sessionKey: opts?.sessionKey, + agentId: opts?.agentId, transformMessageForPersistence: (message) => { const withProvenance = applyInputProvenanceToUserMessage(message, opts?.inputProvenance); const prepared = pendingPreparedUserTurnMessage; diff --git a/src/agents/session-tool-result-guard.transcript-events.test.ts b/src/agents/session-tool-result-guard.transcript-events.test.ts index af3521ead5a4..49968f577c9f 100644 --- a/src/agents/session-tool-result-guard.transcript-events.test.ts +++ b/src/agents/session-tool-result-guard.transcript-events.test.ts @@ -43,6 +43,7 @@ describe("guardSessionManager transcript updates", () => { expect(updates).toStrictEqual([ { + agentId: "main", message: { content: [{ text: "hello from subagent", type: "text" }], role: "assistant", diff --git a/src/agents/session-tool-result-guard.ts b/src/agents/session-tool-result-guard.ts index 1641ef56b56b..1042205bd80a 100644 --- a/src/agents/session-tool-result-guard.ts +++ b/src/agents/session-tool-result-guard.ts @@ -521,6 +521,8 @@ export function installSessionToolResultGuard( opts?: { /** Optional session key for transcript update broadcasts. */ sessionKey?: string; + /** Optional agent id for selected-global transcript update broadcasts. */ + agentId?: string; /** * Optional transform applied to any message before persistence. */ @@ -780,6 +782,7 @@ export function installSessionToolResultGuard( emitSessionTranscriptUpdate({ sessionFile, sessionKey: opts?.sessionKey, + ...(opts?.agentId ? { agentId: opts.agentId } : {}), message: finalMessage, messageId: typeof result === "string" ? result : undefined, ...(messageSeq !== undefined ? { messageSeq } : {}), diff --git a/src/agents/tool-catalog.test.ts b/src/agents/tool-catalog.test.ts index 47035ed13369..0ec48ad5cf0a 100644 --- a/src/agents/tool-catalog.test.ts +++ b/src/agents/tool-catalog.test.ts @@ -41,6 +41,9 @@ describe("tool-catalog", () => { "subagents", "session_status", "cron", + "get_goal", + "create_goal", + "update_goal", "update_plan", "image", "image_generate", diff --git a/src/agents/tool-catalog.ts b/src/agents/tool-catalog.ts index dcb9950b3f25..0b41de15b934 100644 --- a/src/agents/tool-catalog.ts +++ b/src/agents/tool-catalog.ts @@ -260,6 +260,30 @@ const CORE_TOOL_DEFINITIONS: CoreToolDefinition[] = [ profiles: [], includeInOpenClawGroup: true, }, + { + id: "get_goal", + label: "get_goal", + description: "Get current thread goal", + sectionId: "agents", + profiles: ["coding"], + includeInOpenClawGroup: true, + }, + { + id: "create_goal", + label: "create_goal", + description: "Create a thread goal", + sectionId: "agents", + profiles: ["coding"], + includeInOpenClawGroup: true, + }, + { + id: "update_goal", + label: "update_goal", + description: "Complete or block a thread goal", + sectionId: "agents", + profiles: ["coding"], + includeInOpenClawGroup: true, + }, { id: "update_plan", label: "update_plan", diff --git a/src/agents/tool-display-config.ts b/src/agents/tool-display-config.ts index 92af4ed6cf4f..9307ca54dc89 100644 --- a/src/agents/tool-display-config.ts +++ b/src/agents/tool-display-config.ts @@ -249,6 +249,21 @@ export const TOOL_DISPLAY_CONFIG: ToolDisplayConfig = { }, }, }, + get_goal: { + emoji: "🎯", + title: "Get Goal", + detailKeys: [], + }, + create_goal: { + emoji: "🎯", + title: "Create Goal", + detailKeys: ["objective", "token_budget"], + }, + update_goal: { + emoji: "🎯", + title: "Update Goal", + detailKeys: ["status"], + }, update_plan: { emoji: "πŸ—ΊοΈ", title: "Update Plan", diff --git a/src/agents/tool-mutation.test.ts b/src/agents/tool-mutation.test.ts index 13e6cb746d6d..84eb80df8712 100644 --- a/src/agents/tool-mutation.test.ts +++ b/src/agents/tool-mutation.test.ts @@ -61,6 +61,16 @@ describe("tool mutation helpers", () => { buildToolMutationState("subagents", { action: "steer", target: "worker-1" }).mutatingAction, ).toBe(true); expect(buildToolMutationState("subagents", { action: "list" }).mutatingAction).toBe(false); + expect(buildToolMutationState("get_goal", { sessionKey: "agent:main" }).mutatingAction).toBe( + false, + ); + expect(buildToolMutationState("create_goal", { sessionKey: "agent:main" }).mutatingAction).toBe( + true, + ); + expect( + buildToolMutationState("update_goal", { sessionKey: "agent:main", status: "complete" }) + .mutatingAction, + ).toBe(true); }); it("matches tool actions by fingerprint and fails closed on asymmetric data", () => { diff --git a/src/agents/tool-mutation.ts b/src/agents/tool-mutation.ts index 61dc5d2ce0c1..c12e601b24a9 100644 --- a/src/agents/tool-mutation.ts +++ b/src/agents/tool-mutation.ts @@ -19,6 +19,8 @@ const MUTATING_TOOL_NAMES = new Set([ "canvas", "nodes", "session_status", + "create_goal", + "update_goal", ]); // File-mutation tools that operate on the same `path` target identity. @@ -149,6 +151,8 @@ export function isMutatingToolCall(toolName: string, args: unknown): boolean { case "exec": case "bash": case "sessions_send": + case "create_goal": + case "update_goal": return true; case "process": return action != null && PROCESS_MUTATING_ACTIONS.has(action); diff --git a/src/agents/tools/embedded-gateway-stub.test.ts b/src/agents/tools/embedded-gateway-stub.test.ts index e9cf5e002728..8491415c0fcc 100644 --- a/src/agents/tools/embedded-gateway-stub.test.ts +++ b/src/agents/tools/embedded-gateway-stub.test.ts @@ -25,6 +25,11 @@ const runtime = vi.hoisted(() => ({ })), capArrayByJsonBytes: vi.fn((items: unknown[]) => ({ items })), enforceChatHistoryFinalBudget: vi.fn(({ messages }: { messages: unknown[] }) => ({ messages })), + loadCombinedSessionStoreForGateway: vi.fn(() => ({ + storePath: "/tmp/openclaw-sessions.json", + store: {}, + })), + listSessionsFromStoreAsync: vi.fn(async () => ({ sessions: [] })), })); vi.mock("./embedded-gateway-stub.runtime.js", () => runtime); @@ -35,6 +40,29 @@ describe("embedded gateway stub", () => { runtime.resolveSessionKeyFromResolveParams.mockReset(); runtime.projectRecentChatDisplayMessages.mockClear(); runtime.readSessionMessagesAsync.mockClear(); + runtime.loadSessionEntry.mockClear(); + runtime.resolveSessionAgentId.mockClear(); + runtime.loadCombinedSessionStoreForGateway.mockClear(); + runtime.listSessionsFromStoreAsync.mockClear(); + }); + + it("scopes embedded session lists to the requested agent", async () => { + const callGateway = createEmbeddedCallGateway(); + await callGateway({ + method: "sessions.list", + params: { agentId: "work", includeGlobal: true, search: "global" }, + }); + + expect(runtime.loadCombinedSessionStoreForGateway).toHaveBeenCalledWith( + { agents: { list: [{ id: "main", default: true }] } }, + { agentId: "work" }, + ); + expect(runtime.listSessionsFromStoreAsync).toHaveBeenCalledWith({ + cfg: { agents: { list: [{ id: "main", default: true }] } }, + storePath: "/tmp/openclaw-sessions.json", + store: {}, + opts: { agentId: "work", includeGlobal: true, search: "global" }, + }); }); it("resolves sessions through the gateway session resolver", async () => { @@ -104,6 +132,36 @@ describe("embedded gateway stub", () => { expect(result.messages).toEqual(projectedMessages); }); + it("scopes embedded global chat history to the requested agent", async () => { + const callGateway = createEmbeddedCallGateway(); + await callGateway<{ messages: unknown[] }>({ + method: "chat.history", + params: { sessionKey: "global", agentId: "work" }, + }); + + expect(runtime.loadSessionEntry).toHaveBeenCalledWith("global", { agentId: "work" }); + expect(runtime.resolveSessionAgentId).toHaveBeenCalledWith({ + sessionKey: "global", + config: {}, + agentId: "work", + }); + }); + + it("infers embedded global chat history scope from agent-prefixed aliases", async () => { + const callGateway = createEmbeddedCallGateway(); + await callGateway<{ messages: unknown[] }>({ + method: "chat.history", + params: { sessionKey: "agent:work:main" }, + }); + + expect(runtime.loadSessionEntry).toHaveBeenCalledWith("agent:work:main", { agentId: "work" }); + expect(runtime.resolveSessionAgentId).toHaveBeenCalledWith({ + sessionKey: "agent:work:main", + config: {}, + agentId: "work", + }); + }); + it("passes the requested recent history window to projection", async () => { const rawMessages = [ { role: "user", content: "visible older" }, diff --git a/src/agents/tools/embedded-gateway-stub.ts b/src/agents/tools/embedded-gateway-stub.ts index c050327746e8..31d74d6b5441 100644 --- a/src/agents/tools/embedded-gateway-stub.ts +++ b/src/agents/tools/embedded-gateway-stub.ts @@ -7,12 +7,17 @@ import type { CallGatewayOptions } from "../../gateway/call.js"; import type { ReadSessionMessagesAsyncOptions } from "../../gateway/session-utils.fs.js"; import type { SessionsListResult } from "../../gateway/session-utils.types.js"; import type { SessionsResolveResult } from "../../gateway/sessions-resolve.js"; +import { parseAgentSessionKey } from "../../routing/session-key.js"; import { readPositiveIntegerParam } from "./common.js"; type EmbeddedCallGateway = >(opts: CallGatewayOptions) => Promise; interface EmbeddedGatewayRuntime { - resolveSessionAgentId: (opts: { sessionKey: string; config: OpenClawConfig }) => string; + resolveSessionAgentId: (opts: { + sessionKey: string; + config: OpenClawConfig; + agentId?: string; + }) => string; getRuntimeConfig: () => OpenClawConfig; augmentChatHistoryWithCliSessionImports: (opts: { entry: unknown; @@ -41,7 +46,10 @@ interface EmbeddedGatewayRuntime { store: unknown; opts: SessionsListParams; }) => Promise; - loadCombinedSessionStoreForGateway: (cfg: OpenClawConfig) => { + loadCombinedSessionStoreForGateway: ( + cfg: OpenClawConfig, + opts?: { agentId?: string }, + ) => { storePath: string; store: unknown; }; @@ -49,7 +57,10 @@ interface EmbeddedGatewayRuntime { cfg: OpenClawConfig; p: SessionsResolveParams; }) => Promise; - loadSessionEntry: (sessionKey: string) => { + loadSessionEntry: ( + sessionKey: string, + opts?: { agentId?: string }, + ) => { cfg: OpenClawConfig; storePath: string | undefined; entry: Record | undefined; @@ -79,12 +90,15 @@ async function getRuntime(): Promise { async function handleSessionsList(params: Record) { const rt = await getRuntime(); const cfg = rt.getRuntimeConfig(); - const { storePath, store } = rt.loadCombinedSessionStoreForGateway(cfg); + const opts = params as SessionsListParams; + const { storePath, store } = rt.loadCombinedSessionStoreForGateway(cfg, { + agentId: opts.agentId, + }); return rt.listSessionsFromStoreAsync({ cfg, storePath, store, - opts: params as SessionsListParams, + opts, }); } @@ -112,11 +126,19 @@ async function handleChatHistory(params: Record): Promise<{ const rt = await getRuntime(); const sessionKey = typeof params.sessionKey === "string" ? params.sessionKey : ""; + const agentId = typeof params.agentId === "string" ? params.agentId : undefined; + const parsedAgentId = parseAgentSessionKey(sessionKey)?.agentId; + const requestedAgentId = agentId ?? parsedAgentId; const limit = readPositiveIntegerParam(params, "limit"); - const { cfg, storePath, entry } = rt.loadSessionEntry(sessionKey); + const sessionLoadOptions = requestedAgentId ? { agentId: requestedAgentId } : undefined; + const { cfg, storePath, entry } = rt.loadSessionEntry(sessionKey, sessionLoadOptions); const sessionId = entry?.sessionId as string | undefined; - const sessionAgentId = rt.resolveSessionAgentId({ sessionKey, config: cfg }); + const sessionAgentId = rt.resolveSessionAgentId({ + sessionKey, + config: cfg, + agentId: requestedAgentId, + }); const resolvedSessionModel = rt.resolveSessionModelRef(cfg, entry, sessionAgentId); const hardMax = 1000; const defaultLimit = 200; diff --git a/src/agents/tools/goal-tools.test.ts b/src/agents/tools/goal-tools.test.ts new file mode 100644 index 000000000000..63d23d0f7397 --- /dev/null +++ b/src/agents/tools/goal-tools.test.ts @@ -0,0 +1,108 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { resolveStorePath } from "../../config/sessions/paths.js"; +import { loadSessionStore, upsertSessionEntry } from "../../config/sessions/store.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { createCreateGoalTool, createGetGoalTool } from "./goal-tools.js"; + +async function createStoreConfig(): Promise<{ config: OpenClawConfig; template: string }> { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-goal-tools-")); + const template = path.join(dir, "{agentId}", "sessions.json"); + return { + config: { session: { store: template } } as OpenClawConfig, + template, + }; +} + +describe("goal tools", () => { + it("keeps get_goal read-only when accounting changes are projected", async () => { + const { config, template } = await createStoreConfig(); + const storePath = resolveStorePath(template, { agentId: "research" }); + await upsertSessionEntry({ + storePath, + sessionKey: "global", + entry: { + sessionId: "sess-global", + updatedAt: 1, + totalTokens: 125, + totalTokensFresh: true, + goal: { + schemaVersion: 1, + id: "goal-1", + objective: "ship", + status: "active", + createdAt: 1, + updatedAt: 1, + tokenStart: 100, + tokenStartFresh: true, + tokensUsed: 0, + tokenBudget: 20, + continuationTurns: 0, + }, + }, + }); + const tool = createGetGoalTool({ + agentSessionKey: "global", + runSessionKey: "global", + sessionAgentId: "research", + config, + }); + + const result = await tool.execute("call-1", {}); + + expect((result.details as { goal?: { status?: string } }).goal?.status).toBe("budget_limited"); + expect(loadSessionStore(storePath, { skipCache: true }).global?.goal?.status).toBe("active"); + }); + + it("uses the resolved session agent for global session stores", async () => { + const { config, template } = await createStoreConfig(); + const tool = createCreateGoalTool({ + agentSessionKey: "global", + runSessionKey: "global", + sessionAgentId: "research", + config, + }); + + const researchStorePath = resolveStorePath(template, { agentId: "research" }); + await upsertSessionEntry({ + storePath: researchStorePath, + sessionKey: "global", + entry: { sessionId: "sess-global", updatedAt: 1 }, + }); + await tool.execute("call-1", { objective: "ship global work" }); + + const mainStorePath = resolveStorePath(template, { agentId: "main" }); + expect(loadSessionStore(researchStorePath, { skipCache: true }).global?.goal?.objective).toBe( + "ship global work", + ); + expect(loadSessionStore(mainStorePath, { skipCache: true }).global?.goal).toBeUndefined(); + }); + + it("prefers scoped run session keys over the fallback session agent", async () => { + const { config, template } = await createStoreConfig(); + const tool = createCreateGoalTool({ + agentSessionKey: "global", + runSessionKey: "agent:ops:main", + sessionAgentId: "research", + config, + }); + + const opsStorePath = resolveStorePath(template, { agentId: "ops" }); + await upsertSessionEntry({ + storePath: opsStorePath, + sessionKey: "agent:ops:main", + entry: { sessionId: "sess-ops", updatedAt: 1 }, + }); + await tool.execute("call-1", { objective: "ship ops work" }); + + const researchStorePath = resolveStorePath(template, { agentId: "research" }); + expect( + loadSessionStore(opsStorePath, { skipCache: true })["agent:ops:main"]?.goal?.objective, + ).toBe("ship ops work"); + expect( + loadSessionStore(researchStorePath, { skipCache: true })["agent:ops:main"]?.goal, + ).toBeUndefined(); + }); +}); diff --git a/src/agents/tools/goal-tools.ts b/src/agents/tools/goal-tools.ts new file mode 100644 index 000000000000..f0f29d321f13 --- /dev/null +++ b/src/agents/tools/goal-tools.ts @@ -0,0 +1,139 @@ +import { Type } from "typebox"; +import { + createSessionGoal, + getSessionGoal, + MODEL_UPDATABLE_SESSION_GOAL_STATUSES, + updateSessionGoalStatus, +} from "../../config/sessions/goals.js"; +import { resolveStorePath } from "../../config/sessions/paths.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js"; +import { stringEnum } from "../schema/typebox.js"; +import { + type AnyAgentTool, + ToolInputError, + jsonResult, + readNumberParam, + readStringParam, +} from "./common.js"; + +type GoalToolOptions = { + agentSessionKey?: string; + runSessionKey?: string; + sessionAgentId?: string; + config?: OpenClawConfig; +}; + +type GoalSessionScope = { + sessionKey: string; + storePath: string; +}; + +const CreateGoalToolSchema = Type.Object({ + objective: Type.String({ + description: "Concrete objective to pursue. Create only when explicitly requested.", + }), + token_budget: Type.Optional( + Type.Number({ + description: "Optional positive token budget for this goal.", + }), + ), +}); + +const UpdateGoalToolSchema = Type.Object({ + status: stringEnum(MODEL_UPDATABLE_SESSION_GOAL_STATUSES, { + description: "complete | blocked.", + }), + note: Type.Optional(Type.String({ description: "Short status note." })), +}); + +function resolveGoalSessionScope(options: GoalToolOptions): GoalSessionScope { + const sessionKey = options.runSessionKey?.trim() || options.agentSessionKey?.trim(); + if (!sessionKey) { + throw new ToolInputError("session key required"); + } + const parsedSessionAgentId = parseAgentSessionKey(sessionKey)?.agentId; + const parsedAgentSessionAgentId = parseAgentSessionKey(options.agentSessionKey)?.agentId; + const agentId = normalizeAgentId( + parsedSessionAgentId ?? parsedAgentSessionAgentId ?? options.sessionAgentId, + ); + return { + sessionKey, + storePath: resolveStorePath(options.config?.session?.store, { + agentId, + }), + }; +} + +export function createGetGoalTool(options: GoalToolOptions): AnyAgentTool { + return { + label: "Get Goal", + name: "get_goal", + displaySummary: "Get the current thread goal", + description: "Get the current goal for this thread, including status and token usage.", + parameters: Type.Object({}), + execute: async () => { + const snapshot = await getSessionGoal({ + ...resolveGoalSessionScope(options), + persist: false, + }); + return jsonResult(snapshot); + }, + }; +} + +export function createCreateGoalTool(options: GoalToolOptions): AnyAgentTool { + return { + label: "Create Goal", + name: "create_goal", + displaySummary: "Create a thread goal", + description: + "Create a goal only when explicitly requested by the user or system instructions. Fails if a goal already exists; use user-facing goal controls to clear it.", + parameters: CreateGoalToolSchema, + execute: async (_toolCallId, args) => { + const params = args as Record; + const objective = readStringParam(params, "objective", { required: true }); + const tokenBudget = readNumberParam(params, "token_budget", { integer: true }); + if (tokenBudget !== undefined && tokenBudget <= 0) { + throw new ToolInputError("token_budget must be positive"); + } + const goal = await createSessionGoal({ + ...resolveGoalSessionScope(options), + objective, + ...(tokenBudget !== undefined ? { tokenBudget } : {}), + }); + return jsonResult({ status: "created", goal }); + }, + }; +} + +export function createUpdateGoalTool(options: GoalToolOptions): AnyAgentTool { + return { + label: "Update Goal", + name: "update_goal", + displaySummary: "Complete or block a thread goal", + description: + "Mark the current goal complete only when achieved, or blocked only after the same blocking condition recurs for at least three consecutive goal turns. Do not use blocked for ordinary difficulty or missing polish.", + parameters: UpdateGoalToolSchema, + execute: async (_toolCallId, args) => { + const params = args as Record; + const status = readStringParam(params, "status", { required: true }); + if ( + !MODEL_UPDATABLE_SESSION_GOAL_STATUSES.includes( + status as (typeof MODEL_UPDATABLE_SESSION_GOAL_STATUSES)[number], + ) + ) { + throw new ToolInputError( + `status must be one of ${MODEL_UPDATABLE_SESSION_GOAL_STATUSES.join(", ")}`, + ); + } + const note = readStringParam(params, "note"); + const goal = await updateSessionGoalStatus({ + ...resolveGoalSessionScope(options), + status: status as (typeof MODEL_UPDATABLE_SESSION_GOAL_STATUSES)[number], + ...(note ? { note } : {}), + }); + return jsonResult({ status: "updated", goal }); + }, + }; +} diff --git a/src/auto-reply/commands-registry.shared.ts b/src/auto-reply/commands-registry.shared.ts index a1517bcccaee..75d8ed10bd6c 100644 --- a/src/auto-reply/commands-registry.shared.ts +++ b/src/auto-reply/commands-registry.shared.ts @@ -220,6 +220,29 @@ export function buildBuiltinChatCommands( category: "status", tier: "essential", }), + defineChatCommand({ + key: "goal", + nativeName: "goal", + description: "Show or control the current goal.", + textAlias: "/goal", + category: "status", + tier: "standard", + acceptsArgs: true, + args: [ + { + name: "action", + description: "status, start, pause, resume, complete, block, clear", + type: "string", + choices: ["status", "start", "pause", "resume", "complete", "block", "clear"], + }, + { + name: "text", + description: "Goal objective or note", + type: "string", + captureRemaining: true, + }, + ], + }), defineChatCommand({ key: "diagnostics", nativeName: "diagnostics", diff --git a/src/auto-reply/reply/commands-goal.ts b/src/auto-reply/reply/commands-goal.ts new file mode 100644 index 000000000000..cc18bf15998e --- /dev/null +++ b/src/auto-reply/reply/commands-goal.ts @@ -0,0 +1,161 @@ +import { + clearSessionGoal, + createSessionGoal, + formatSessionGoalStatus, + getSessionEntry, + getSessionGoal, + updateSessionGoalStatus, +} from "../../config/sessions.js"; +import { + normalizeOptionalLowercaseString, + normalizeOptionalString, +} from "../../shared/string-coerce.js"; +import { rejectUnauthorizedCommand } from "./command-gates.js"; +import type { + CommandHandler, + CommandHandlerResult, + HandleCommandsParams, +} from "./commands-types.js"; + +const GOAL_COMMAND_PREFIX = "/goal"; + +export function parseGoalCommand(raw: string): { action: string; text: string } | null { + const trimmed = raw.trim(); + const commandEnd = trimmed.search(/\s/); + const commandToken = commandEnd === -1 ? trimmed : trimmed.slice(0, commandEnd); + if (normalizeOptionalLowercaseString(commandToken) !== GOAL_COMMAND_PREFIX) { + return null; + } + const argText = commandEnd === -1 ? "" : trimmed.slice(commandEnd).trim(); + if (!argText) { + return { action: "status", text: "" }; + } + const [actionRaw = "", ...rest] = argText.split(/\s+/); + return { + action: normalizeOptionalLowercaseString(actionRaw) ?? "status", + text: rest.join(" ").trim(), + }; +} + +function syncGoalSessionEntry(params: HandleCommandsParams): void { + if (!params.sessionStore || !params.sessionKey) { + return; + } + const entry = getSessionEntry({ sessionKey: params.sessionKey, storePath: params.storePath }); + if (!entry) { + return; + } + params.sessionStore[params.sessionKey] = entry; + params.sessionEntry = entry; +} + +function goalReply(text: string): CommandHandlerResult { + return { + shouldContinue: false, + reply: { text }, + }; +} + +function goalErrorReply(error: unknown): CommandHandlerResult { + const message = error instanceof Error ? error.message : String(error); + return goalReply(`Goal error: ${message}`); +} + +export const handleGoalCommand: CommandHandler = async (params, allowTextCommands) => { + if (!allowTextCommands) { + return null; + } + const parsed = parseGoalCommand(params.command.commandBodyNormalized); + if (!parsed) { + return null; + } + const unauthorized = rejectUnauthorizedCommand(params, "/goal"); + if (unauthorized) { + return unauthorized; + } + + try { + switch (parsed.action) { + case "status": { + const snapshot = await getSessionGoal({ + sessionKey: params.sessionKey, + storePath: params.storePath, + }); + syncGoalSessionEntry(params); + return goalReply(formatSessionGoalStatus(snapshot.goal)); + } + case "start": + case "set": + case "create": { + const objective = normalizeOptionalString(parsed.text); + if (!objective) { + return goalReply("Usage: /goal start "); + } + const goal = await createSessionGoal({ + sessionKey: params.sessionKey, + storePath: params.storePath, + objective, + fallbackEntry: params.sessionEntry, + }); + syncGoalSessionEntry(params); + return goalReply(`Goal started: ${goal.objective}`); + } + case "pause": { + const goal = await updateSessionGoalStatus({ + sessionKey: params.sessionKey, + storePath: params.storePath, + status: "paused", + ...(parsed.text ? { note: parsed.text } : {}), + }); + syncGoalSessionEntry(params); + return goalReply(`Goal paused: ${goal.objective}`); + } + case "resume": { + const goal = await updateSessionGoalStatus({ + sessionKey: params.sessionKey, + storePath: params.storePath, + status: "active", + ...(parsed.text ? { note: parsed.text } : {}), + }); + syncGoalSessionEntry(params); + return goalReply(`Goal resumed: ${goal.objective}`); + } + case "complete": + case "done": { + const goal = await updateSessionGoalStatus({ + sessionKey: params.sessionKey, + storePath: params.storePath, + status: "complete", + ...(parsed.text ? { note: parsed.text } : {}), + }); + syncGoalSessionEntry(params); + return goalReply(`Goal complete: ${goal.objective}\nTokens used: ${goal.tokensUsed}`); + } + case "block": + case "blocked": { + const goal = await updateSessionGoalStatus({ + sessionKey: params.sessionKey, + storePath: params.storePath, + status: "blocked", + ...(parsed.text ? { note: parsed.text } : {}), + }); + syncGoalSessionEntry(params); + return goalReply(`Goal blocked: ${goal.objective}`); + } + case "clear": { + const removed = await clearSessionGoal({ + sessionKey: params.sessionKey, + storePath: params.storePath, + }); + syncGoalSessionEntry(params); + return goalReply(removed ? "Goal cleared." : "No goal to clear."); + } + default: + return goalReply( + "Usage: /goal [status] | /goal start | /goal pause|resume|complete|block|clear", + ); + } + } catch (error) { + return goalErrorReply(error); + } +}; diff --git a/src/auto-reply/reply/commands-handlers.runtime.ts b/src/auto-reply/reply/commands-handlers.runtime.ts index e98238bff4c5..abcbff27074a 100644 --- a/src/auto-reply/reply/commands-handlers.runtime.ts +++ b/src/auto-reply/reply/commands-handlers.runtime.ts @@ -9,6 +9,7 @@ import { handleContextCommand } from "./commands-context-command.js"; import { handleCrestodianCommand } from "./commands-crestodian.js"; import { handleDiagnosticsCommand } from "./commands-diagnostics.js"; import { handleDockCommand } from "./commands-dock.js"; +import { handleGoalCommand } from "./commands-goal.js"; import { handleCommandsListCommand, handleExportTrajectoryCommand, @@ -59,6 +60,7 @@ export function loadCommandHandlers(): CommandHandler[] { handleSkillCommandUsage, handleToolsCommand, handleStatusCommand, + handleGoalCommand, handleDiagnosticsCommand, handleTasksCommand, handleSteerCommand, diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index d39fd7194c7f..1c9eb524d0c6 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -342,7 +342,7 @@ const resolveSessionStoreLookup = ( if (!sessionKey) { return {}; } - const agentId = resolveSessionAgentId({ sessionKey, config: cfg }); + const agentId = resolveSessionAgentId({ sessionKey, config: cfg, agentId: ctx.AgentId }); const storePath = resolveStorePath(cfg.session?.store, { agentId }); try { const store = loadSessionStore(storePath); @@ -1114,7 +1114,11 @@ export async function dispatchReplyFromConfig( const sessionStoreEntry = boundAcpDispatchSessionKey ? resolveSessionStoreLookup({ ...ctx, SessionKey: boundAcpDispatchSessionKey }, cfg) : initialSessionStoreEntry; - const sessionAgentId = resolveSessionAgentId({ sessionKey: acpDispatchSessionKey, config: cfg }); + const sessionAgentId = resolveSessionAgentId({ + sessionKey: acpDispatchSessionKey, + config: cfg, + agentId: ctx.AgentId, + }); const sessionAgentCfg = resolveAgentConfig(cfg, sessionAgentId); const verboseProgress = createShouldEmitVerboseProgress({ sessionKey: acpDispatchSessionKey, diff --git a/src/auto-reply/reply/get-reply.ts b/src/auto-reply/reply/get-reply.ts index ebc4c36e1d60..055884d60640 100644 --- a/src/auto-reply/reply/get-reply.ts +++ b/src/auto-reply/reply/get-reply.ts @@ -277,6 +277,7 @@ export async function getReplyFromConfig( agentId: resolveSessionAgentId({ sessionKey: resolvedAgentSessionKey, config: cfg, + agentId: finalized.AgentId, }), }; }, diff --git a/src/auto-reply/reply/session-usage.ts b/src/auto-reply/reply/session-usage.ts index cc93747bad61..65326a3d318d 100644 --- a/src/auto-reply/reply/session-usage.ts +++ b/src/auto-reply/reply/session-usage.ts @@ -6,6 +6,7 @@ import { } from "../../agents/usage.js"; import { getRuntimeConfig } from "../../config/config.js"; import { + resolveSessionGoalDisplayState, type SessionSystemPromptReport, type SessionEntry, updateSessionStoreEntry, @@ -124,6 +125,7 @@ export async function persistSessionUsageUpdate(params: { skipMaintenance: true, takeCacheOwnership: true, update: async (entry) => { + const updatedAt = Date.now(); const preserveSessionModelState = params.isHeartbeat === true || params.preserveUserFacingSessionModelState === true; const preserveUserFacingRunState = params.preserveUserFacingSessionModelState === true; @@ -173,7 +175,7 @@ export async function persistSessionUsageUpdate(params: { systemPromptReport: preserveUserFacingRunState ? entry.systemPromptReport : (params.systemPromptReport ?? entry.systemPromptReport), - updatedAt: Date.now(), + updatedAt, }; if (hasUsage && !preserveUserFacingRunState) { patch.inputTokens = params.usage?.input ?? 0; @@ -200,6 +202,10 @@ export async function persistSessionUsageUpdate(params: { if ((hasFreshContextSnapshot || hasCompactionSnapshot) && !preserveUserFacingRunState) { patch.totalTokens = totalTokens; patch.totalTokensFresh = true; + const accountedGoal = resolveSessionGoalDisplayState({ ...entry, ...patch }, updatedAt); + if (accountedGoal) { + patch.goal = accountedGoal; + } } else if ( !preserveUserFacingRunState && (params.preserveFreshTotalTokensOnStaleUsage !== true || diff --git a/src/auto-reply/reply/session.test.ts b/src/auto-reply/reply/session.test.ts index 4788a419663e..3b3b82df20e7 100644 --- a/src/auto-reply/reply/session.test.ts +++ b/src/auto-reply/reply/session.test.ts @@ -3070,6 +3070,59 @@ describe("persistSessionUsageUpdate", () => { expect(stored[sessionKey].outputTokens).toBe(10_000); }); + it("accounts goal usage when fresh token snapshots are persisted", async () => { + const storePath = await createStorePath("openclaw-usage-goal-"); + const sessionKey = "main"; + await seedSessionStore({ + storePath, + sessionKey, + entry: { + sessionId: "s1", + updatedAt: 1, + goal: { + schemaVersion: 1, + id: "goal-1", + objective: "ship", + status: "active", + createdAt: 1, + updatedAt: 1, + tokenStart: 0, + tokenStartFresh: false, + tokensUsed: 0, + tokenBudget: 20, + continuationTurns: 0, + }, + }, + }); + + await persistSessionUsageUpdate({ + storePath, + sessionKey, + usage: { input: 100, output: 5, total: 105 }, + lastCallUsage: { input: 100, output: 5, total: 105 }, + contextTokensUsed: 200_000, + }); + + const stored1 = JSON.parse(await fs.readFile(storePath, "utf-8")); + expect(stored1[sessionKey].goal.tokenStart).toBe(100); + expect(stored1[sessionKey].goal.tokenStartFresh).toBe(true); + expect(stored1[sessionKey].goal.tokensUsed).toBe(0); + expect(stored1[sessionKey].goal.status).toBe("active"); + + await persistSessionUsageUpdate({ + storePath, + sessionKey, + usage: { input: 125, output: 5, total: 130 }, + lastCallUsage: { input: 125, output: 5, total: 130 }, + contextTokensUsed: 200_000, + }); + + const stored2 = JSON.parse(await fs.readFile(storePath, "utf-8")); + expect(stored2[sessionKey].goal.tokenStart).toBe(100); + expect(stored2[sessionKey].goal.tokensUsed).toBe(25); + expect(stored2[sessionKey].goal.status).toBe("budget_limited"); + }); + it("uses lastCallUsage cache counters when available", async () => { const storePath = await createStorePath("openclaw-usage-cache-"); const sessionKey = "main"; diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index b44a2f613233..d9e4d7c7507e 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -797,6 +797,7 @@ export async function initSessionState(params: { sessionEntry.estimatedCostUsd = undefined; sessionEntry.contextTokens = undefined; sessionEntry.contextBudgetStatus = undefined; + sessionEntry.goal = undefined; // Skills snapshots are prompt/runtime caches. Do not preserve a stale // snapshot through /new; the next turn must rebuild the visible skill list. sessionEntry.skillsSnapshot = undefined; diff --git a/src/auto-reply/templating.ts b/src/auto-reply/templating.ts index 74ca163d1a28..3a9a33326670 100644 --- a/src/auto-reply/templating.ts +++ b/src/auto-reply/templating.ts @@ -102,6 +102,11 @@ export type MsgContext = { From?: string; To?: string; SessionKey?: string; + /** + * Resolved agent scope for canonical session keys that do not encode the agent + * id, such as selected-agent global sessions. + */ + AgentId?: string; /** * Session-like key used for runtime policy (sandbox/tool policy) when the * conversation key intentionally remains broader, such as a main-session DM. diff --git a/src/config/sessions.ts b/src/config/sessions.ts index 8c73b55c2bd1..635c9687d727 100644 --- a/src/config/sessions.ts +++ b/src/config/sessions.ts @@ -1,5 +1,6 @@ export * from "./sessions/combined-store-gateway.js"; export * from "./sessions/group.js"; +export * from "./sessions/goals.js"; export * from "./sessions/artifacts.js"; export * from "./sessions/metadata.js"; export * from "./sessions/main-session.js"; diff --git a/src/config/sessions/goals.test.ts b/src/config/sessions/goals.test.ts new file mode 100644 index 000000000000..a4c148036f20 --- /dev/null +++ b/src/config/sessions/goals.test.ts @@ -0,0 +1,418 @@ +import { describe, expect, it } from "vitest"; +import { + clearSessionGoal, + createSessionGoal, + formatSessionGoalStatus, + getSessionGoal, + resolveSessionGoalDisplayState, + updateSessionGoalStatus, +} from "./goals.js"; +import { getSessionEntry, upsertSessionEntry } from "./store.js"; +import { useTempSessionsFixture } from "./test-helpers.js"; + +describe("session goals", () => { + const fixture = useTempSessionsFixture("openclaw-session-goals-"); + const sessionKey = "agent:main:telegram:direct:123"; + + async function writeSession(totalTokens = 0) { + await upsertSessionEntry({ + storePath: fixture.storePath(), + sessionKey, + entry: { + sessionId: "sess-1", + updatedAt: 1, + totalTokens, + totalTokensFresh: true, + }, + }); + } + + it("creates core-owned goal state on the session entry", async () => { + await upsertSessionEntry({ + storePath: fixture.storePath(), + sessionKey, + entry: { + ...getSessionEntry({ storePath: fixture.storePath(), sessionKey })!, + totalTokens: 100, + }, + }); + + const goal = await createSessionGoal({ + storePath: fixture.storePath(), + sessionKey, + objective: "land the PR", + tokenBudget: 50, + now: 10, + }); + + expect(goal.objective).toBe("land the PR"); + expect(goal.status).toBe("active"); + expect(goal.tokenStart).toBe(100); + expect(goal.tokenStartFresh).toBe(true); + expect(goal.tokenBudget).toBe(50); + expect(getSessionEntry({ storePath: fixture.storePath(), sessionKey })?.goal?.id).toBe(goal.id); + }); + + it("can create a goal from a fallback session entry", async () => { + const goal = await createSessionGoal({ + storePath: fixture.storePath(), + sessionKey, + objective: "native slash start", + fallbackEntry: { + sessionId: "sess-1", + updatedAt: 1, + totalTokens: 10, + totalTokensFresh: true, + }, + now: 10, + }); + + expect(goal.tokenStart).toBe(10); + expect(getSessionEntry({ storePath: fixture.storePath(), sessionKey })?.goal?.objective).toBe( + "native slash start", + ); + }); + + it("accounts usage from session token snapshots and enforces budget", async () => { + await writeSession(100); + await createSessionGoal({ + storePath: fixture.storePath(), + sessionKey, + objective: "finish task", + tokenBudget: 20, + now: 10, + }); + await upsertSessionEntry({ + storePath: fixture.storePath(), + sessionKey, + entry: { + ...getSessionEntry({ storePath: fixture.storePath(), sessionKey })!, + totalTokens: 125, + }, + }); + + const snapshot = await getSessionGoal({ storePath: fixture.storePath(), sessionKey, now: 20 }); + + expect(snapshot.goal?.tokensUsed).toBe(25); + expect(snapshot.goal?.status).toBe("budget_limited"); + }); + + it("resumes budget-limited goals with a fresh budget window", async () => { + await writeSession(100); + await createSessionGoal({ + storePath: fixture.storePath(), + sessionKey, + objective: "finish task", + tokenBudget: 20, + now: 10, + }); + await upsertSessionEntry({ + storePath: fixture.storePath(), + sessionKey, + entry: { + ...getSessionEntry({ storePath: fixture.storePath(), sessionKey })!, + totalTokens: 125, + }, + }); + await getSessionGoal({ storePath: fixture.storePath(), sessionKey, now: 20 }); + + const resumed = await updateSessionGoalStatus({ + storePath: fixture.storePath(), + sessionKey, + status: "active", + now: 30, + }); + const snapshot = await getSessionGoal({ storePath: fixture.storePath(), sessionKey, now: 40 }); + + expect(resumed.status).toBe("active"); + expect(resumed.tokenStart).toBe(125); + expect(resumed.tokensUsed).toBe(0); + expect(snapshot.goal?.status).toBe("active"); + expect(snapshot.goal?.tokensUsed).toBe(0); + }); + + it("ignores stale token snapshots for budget accounting", async () => { + await upsertSessionEntry({ + storePath: fixture.storePath(), + sessionKey, + entry: { + sessionId: "sess-1", + updatedAt: 1, + totalTokens: 100, + totalTokensFresh: false, + }, + }); + await createSessionGoal({ + storePath: fixture.storePath(), + sessionKey, + objective: "finish task", + tokenBudget: 20, + now: 10, + }); + await upsertSessionEntry({ + storePath: fixture.storePath(), + sessionKey, + entry: { + ...getSessionEntry({ storePath: fixture.storePath(), sessionKey })!, + totalTokens: 125, + totalTokensFresh: false, + }, + }); + + const snapshot = await getSessionGoal({ storePath: fixture.storePath(), sessionKey, now: 20 }); + + expect(snapshot.goal?.tokenStart).toBe(0); + expect(snapshot.goal?.tokenStartFresh).toBe(false); + expect(snapshot.goal?.tokensUsed).toBe(0); + expect(snapshot.goal?.status).toBe("active"); + }); + + it("adopts the first fresh token snapshot as the baseline after stale goal creation", async () => { + await upsertSessionEntry({ + storePath: fixture.storePath(), + sessionKey, + entry: { + sessionId: "sess-1", + updatedAt: 1, + totalTokens: 100, + totalTokensFresh: false, + }, + }); + await createSessionGoal({ + storePath: fixture.storePath(), + sessionKey, + objective: "finish task", + tokenBudget: 20, + now: 10, + }); + await upsertSessionEntry({ + storePath: fixture.storePath(), + sessionKey, + entry: { + ...getSessionEntry({ storePath: fixture.storePath(), sessionKey })!, + totalTokens: 125, + totalTokensFresh: true, + }, + }); + + const snapshot = await getSessionGoal({ storePath: fixture.storePath(), sessionKey, now: 20 }); + + expect(snapshot.goal?.tokenStart).toBe(125); + expect(snapshot.goal?.tokenStartFresh).toBe(true); + expect(snapshot.goal?.tokensUsed).toBe(0); + expect(snapshot.goal?.status).toBe("active"); + }); + + it("treats token snapshots as fresh unless explicitly stale", async () => { + await upsertSessionEntry({ + storePath: fixture.storePath(), + sessionKey, + entry: { + sessionId: "sess-1", + updatedAt: 1, + totalTokens: 100, + }, + }); + await createSessionGoal({ + storePath: fixture.storePath(), + sessionKey, + objective: "finish task", + now: 10, + }); + await upsertSessionEntry({ + storePath: fixture.storePath(), + sessionKey, + entry: { + ...getSessionEntry({ storePath: fixture.storePath(), sessionKey })!, + totalTokens: 125, + }, + }); + + const snapshot = await getSessionGoal({ storePath: fixture.storePath(), sessionKey, now: 20 }); + + expect(snapshot.goal?.tokenStart).toBe(100); + expect(snapshot.goal?.tokensUsed).toBe(25); + }); + + it("lets model tools complete or block but keeps existing terminal state", async () => { + await writeSession(0); + await createSessionGoal({ + storePath: fixture.storePath(), + sessionKey, + objective: "ship", + now: 10, + }); + + const completed = await updateSessionGoalStatus({ + storePath: fixture.storePath(), + sessionKey, + status: "complete", + note: "done", + now: 20, + }); + + expect(completed.status).toBe("complete"); + expect(completed.lastStatusNote).toBe("done"); + await expect( + updateSessionGoalStatus({ + storePath: fixture.storePath(), + sessionKey, + status: "blocked", + now: 30, + }), + ).rejects.toThrow(/already complete/); + }); + + it("lets users resume blocked goals", async () => { + await writeSession(0); + await createSessionGoal({ + storePath: fixture.storePath(), + sessionKey, + objective: "ship", + now: 10, + }); + + await updateSessionGoalStatus({ + storePath: fixture.storePath(), + sessionKey, + status: "blocked", + note: "waiting on CI", + now: 20, + }); + const resumed = await updateSessionGoalStatus({ + storePath: fixture.storePath(), + sessionKey, + status: "active", + now: 30, + }); + + expect(resumed.status).toBe("active"); + expect(resumed.lastStatusNote).toBe("waiting on CI"); + }); + + it("resumes paused goals with a fresh budget window after usage passes the budget", async () => { + await writeSession(0); + await createSessionGoal({ + storePath: fixture.storePath(), + sessionKey, + objective: "ship", + tokenBudget: 20, + now: 10, + }); + await updateSessionGoalStatus({ + storePath: fixture.storePath(), + sessionKey, + status: "paused", + now: 20, + }); + await upsertSessionEntry({ + storePath: fixture.storePath(), + sessionKey, + entry: { + ...getSessionEntry({ storePath: fixture.storePath(), sessionKey })!, + totalTokens: 100, + }, + }); + + const resumed = await updateSessionGoalStatus({ + storePath: fixture.storePath(), + sessionKey, + status: "active", + now: 30, + }); + + expect(resumed.status).toBe("active"); + expect(resumed.tokenStart).toBe(100); + expect(resumed.tokensUsed).toBe(0); + expect(resumed.budgetLimitedAt).toBeUndefined(); + }); + + it("formats a readable status summary with command hints", () => { + const text = formatSessionGoalStatus({ + schemaVersion: 1, + id: "goal-1", + objective: "land the PR", + status: "blocked", + createdAt: 1, + updatedAt: 2, + tokenStart: 0, + tokensUsed: 12_000, + tokenBudget: 30_000, + continuationTurns: 0, + lastStatusNote: "waiting on review", + }); + + expect(text).toContain("Goal\nStatus: blocked\nObjective: land the PR"); + expect(text).toContain("Token budget: 12k/30k"); + expect(text).toContain("Commands: /goal resume, /goal clear"); + }); + + it("projects display state from fresh session tokens", () => { + const goal = resolveSessionGoalDisplayState( + { + totalTokens: 140, + totalTokensFresh: true, + goal: { + schemaVersion: 1, + id: "goal-1", + objective: "finish", + status: "active", + createdAt: 1, + updatedAt: 1, + tokenStart: 100, + tokensUsed: 0, + tokenBudget: 40, + continuationTurns: 0, + }, + }, + 20, + ); + + expect(goal?.tokensUsed).toBe(40); + expect(goal?.status).toBe("budget_limited"); + }); + + it("can project without adopting a stale baseline for read-only displays", () => { + const goal = resolveSessionGoalDisplayState( + { + totalTokens: 140, + totalTokensFresh: true, + goal: { + schemaVersion: 1, + id: "goal-1", + objective: "finish", + status: "active", + createdAt: 1, + updatedAt: 1, + tokenStart: 0, + tokenStartFresh: false, + tokensUsed: 0, + tokenBudget: 40, + continuationTurns: 0, + }, + }, + 20, + { adoptFreshBaseline: false }, + ); + + expect(goal?.tokenStart).toBe(0); + expect(goal?.tokenStartFresh).toBe(false); + expect(goal?.tokensUsed).toBe(0); + expect(goal?.status).toBe("active"); + }); + + it("clears goal state", async () => { + await writeSession(0); + await createSessionGoal({ + storePath: fixture.storePath(), + sessionKey, + objective: "ship", + now: 10, + }); + + await expect(clearSessionGoal({ storePath: fixture.storePath(), sessionKey })).resolves.toBe( + true, + ); + expect(getSessionEntry({ storePath: fixture.storePath(), sessionKey })?.goal).toBeUndefined(); + }); +}); diff --git a/src/config/sessions/goals.ts b/src/config/sessions/goals.ts new file mode 100644 index 000000000000..3cd11f1d988f --- /dev/null +++ b/src/config/sessions/goals.ts @@ -0,0 +1,317 @@ +import crypto from "node:crypto"; +import { getSessionEntry, patchSessionEntry } from "./store.js"; +import { resolveFreshSessionTotalTokens } from "./types.js"; +import type { SessionEntry, SessionGoal, SessionGoalStatus } from "./types.js"; + +export type SessionGoalSnapshot = { + status: "missing" | "found"; + goal?: SessionGoal; +}; + +type SessionGoalStoreOptions = { + sessionKey: string; + storePath?: string; + now?: number; + fallbackEntry?: SessionEntry; + persist?: boolean; +}; + +type CreateSessionGoalOptions = SessionGoalStoreOptions & { + objective: string; + tokenBudget?: number; +}; + +type UpdateSessionGoalStatusOptions = SessionGoalStoreOptions & { + status: Extract; + note?: string; +}; + +export const MODEL_UPDATABLE_SESSION_GOAL_STATUSES = ["complete", "blocked"] as const; + +const TERMINAL_GOAL_STATUSES = new Set(["complete"]); + +function nowMs(value: number | undefined): number { + return typeof value === "number" && Number.isFinite(value) ? value : Date.now(); +} + +function normalizeTokenCount(value: number | undefined): number | undefined { + return typeof value === "number" && Number.isFinite(value) && value >= 0 + ? Math.floor(value) + : undefined; +} + +function resolveEntryFreshTotalTokens( + entry: Pick, +): number | undefined { + return normalizeTokenCount(resolveFreshSessionTotalTokens(entry)); +} + +function resolveEntryGoalStartTokens( + entry: Pick, +): number { + return resolveEntryFreshTotalTokens(entry) ?? 0; +} + +function normalizeTokenBudget(value: number | undefined): number | undefined { + const normalized = normalizeTokenCount(value); + return normalized && normalized > 0 ? normalized : undefined; +} + +function cloneGoal(goal: SessionGoal): SessionGoal { + return { ...goal }; +} + +function formatGoalTokenCount(value: number | undefined): string { + if (value === undefined || !Number.isFinite(value)) { + return "0"; + } + const safe = Math.max(0, value); + if (safe >= 1_000_000) { + return `${(safe / 1_000_000).toFixed(1)}m`; + } + if (safe >= 1_000) { + const precision = safe >= 10_000 ? 0 : 1; + const formattedThousands = (safe / 1_000).toFixed(precision); + if (Number(formattedThousands) >= 1_000) { + return `${(safe / 1_000_000).toFixed(1)}m`; + } + return `${formattedThousands}k`; + } + return String(Math.round(safe)); +} + +export function resolveSessionGoalDisplayState( + entry: Pick, + now?: number, + options?: { adoptFreshBaseline?: boolean }, +): SessionGoal | undefined { + return accountGoalUsage(entry, nowMs(now), options); +} + +function accountGoalUsage( + entry: Pick, + now: number, + options?: { adoptFreshBaseline?: boolean }, +): SessionGoal | undefined { + // `goal` is introduced here as a core-owned slot; no shipped plugin-owned + // goal state exists to migrate, and plugin slot registration now reserves it. + const goal = entry.goal; + if (!goal) { + return undefined; + } + const totalTokens = resolveEntryFreshTotalTokens(entry); + const hasFreshStart = goal.tokenStartFresh !== false; + const shouldHoldStaleStart = !hasFreshStart && options?.adoptFreshBaseline === false; + const shouldAdoptFreshStart = + !shouldHoldStaleStart && totalTokens !== undefined && !hasFreshStart; + const tokenStart = shouldAdoptFreshStart + ? totalTokens + : (normalizeTokenCount(goal.tokenStart) ?? totalTokens ?? 0); + const tokensUsed = + totalTokens === undefined || shouldAdoptFreshStart || shouldHoldStaleStart + ? goal.tokensUsed + : Math.max(goal.tokensUsed, Math.max(0, totalTokens - tokenStart)); + const next: SessionGoal = { + ...goal, + tokenStart, + tokenStartFresh: hasFreshStart || shouldAdoptFreshStart, + tokensUsed, + }; + if ( + next.status === "active" && + next.tokenBudget !== undefined && + tokensUsed >= next.tokenBudget + ) { + next.status = "budget_limited"; + next.budgetLimitedAt = now; + next.updatedAt = now; + } + return next; +} + +function goalsEqual(a: SessionGoal | undefined, b: SessionGoal | undefined): boolean { + return JSON.stringify(a) === JSON.stringify(b); +} + +export function formatSessionGoalStatus(goal: SessionGoal | undefined): string { + if (!goal) { + return "No goal for this session.\nStart one with /goal start ."; + } + const budget = + goal.tokenBudget === undefined + ? "" + : `\nToken budget: ${formatGoalTokenCount(goal.tokensUsed)}/${formatGoalTokenCount(goal.tokenBudget)}`; + const note = goal.lastStatusNote ? `\nNote: ${goal.lastStatusNote}` : ""; + const commands = resolveGoalCommandHint(goal.status); + return [ + "Goal", + `Status: ${goal.status}`, + `Objective: ${goal.objective}`, + `Tokens used: ${formatGoalTokenCount(goal.tokensUsed)}`, + ...(budget ? [budget.slice(1)] : []), + ...(note ? [note.slice(1)] : []), + "", + `Commands: ${commands}`, + ].join("\n"); +} + +function resolveGoalCommandHint(status: SessionGoalStatus): string { + switch (status) { + case "active": + return "/goal pause, /goal complete, /goal clear"; + case "paused": + case "blocked": + case "usage_limited": + case "budget_limited": + return "/goal resume, /goal clear"; + case "complete": + return "/goal clear"; + } + return "/goal"; +} + +export async function getSessionGoal( + options: SessionGoalStoreOptions, +): Promise { + const now = nowMs(options.now); + if (options.persist === false) { + const entry = + getSessionEntry({ sessionKey: options.sessionKey, storePath: options.storePath }) ?? + options.fallbackEntry; + const projected = entry + ? resolveSessionGoalDisplayState(entry, now, { adoptFreshBaseline: false }) + : undefined; + return projected ? { status: "found", goal: projected } : { status: "missing" }; + } + let goal: SessionGoal | undefined; + const result = await patchSessionEntry({ + sessionKey: options.sessionKey, + storePath: options.storePath, + fallbackEntry: options.fallbackEntry, + update: (entry) => { + const accounted = accountGoalUsage(entry, now); + goal = accounted ? cloneGoal(accounted) : undefined; + if (!accounted || goalsEqual(accounted, entry.goal)) { + return null; + } + return { goal: accounted }; + }, + }); + if (!result || !goal) { + return { status: "missing" }; + } + return { status: "found", goal }; +} + +export async function createSessionGoal(options: CreateSessionGoalOptions): Promise { + const objective = options.objective.trim(); + if (!objective) { + throw new Error("objective required"); + } + const now = nowMs(options.now); + let created: SessionGoal | undefined; + const result = await patchSessionEntry({ + sessionKey: options.sessionKey, + storePath: options.storePath, + fallbackEntry: options.fallbackEntry, + update: (entry) => { + if (entry.goal) { + throw new Error("goal already exists"); + } + const tokenBudget = normalizeTokenBudget(options.tokenBudget); + const tokenStartFresh = resolveEntryFreshTotalTokens(entry) !== undefined; + created = { + schemaVersion: 1, + id: crypto.randomUUID(), + objective, + status: "active", + createdAt: now, + updatedAt: now, + tokenStart: resolveEntryGoalStartTokens(entry), + tokenStartFresh, + tokensUsed: 0, + ...(tokenBudget ? { tokenBudget } : {}), + continuationTurns: 0, + }; + return { goal: created }; + }, + }); + if (!result || !created) { + throw new Error("session not found"); + } + return cloneGoal(created); +} + +export async function updateSessionGoalStatus( + options: UpdateSessionGoalStatusOptions, +): Promise { + const now = nowMs(options.now); + let updated: SessionGoal | undefined; + let foundSession = false; + const result = await patchSessionEntry({ + sessionKey: options.sessionKey, + storePath: options.storePath, + update: (entry) => { + foundSession = true; + const accounted = accountGoalUsage(entry, now); + if (!accounted) { + throw new Error("goal not found"); + } + if (TERMINAL_GOAL_STATUSES.has(accounted.status) && accounted.status !== options.status) { + throw new Error(`goal is already ${accounted.status}`); + } + const resetsBudgetWindow = + options.status === "active" && + (accounted.status === "budget_limited" || + accounted.status === "usage_limited" || + (accounted.tokenBudget !== undefined && accounted.tokensUsed >= accounted.tokenBudget)); + const freshTokenStart = resetsBudgetWindow ? resolveEntryFreshTotalTokens(entry) : undefined; + const next: SessionGoal = { + ...accounted, + status: options.status, + updatedAt: now, + ...(options.note ? { lastStatusNote: options.note } : {}), + ...(options.status === "paused" ? { pausedAt: now } : {}), + ...(options.status === "blocked" ? { blockedAt: now } : {}), + ...(options.status === "complete" ? { completedAt: now } : {}), + }; + if (resetsBudgetWindow) { + next.tokenStart = freshTokenStart ?? 0; + next.tokenStartFresh = freshTokenStart !== undefined; + next.tokensUsed = 0; + delete next.budgetLimitedAt; + delete next.usageLimitedAt; + } + if ( + next.status === "active" && + next.tokenBudget !== undefined && + next.tokensUsed >= next.tokenBudget + ) { + next.status = "budget_limited"; + next.budgetLimitedAt = now; + } + updated = next; + return { goal: updated }; + }, + }); + if (!result || !updated) { + throw new Error(foundSession ? "goal not found" : "session not found"); + } + return cloneGoal(updated); +} + +export async function clearSessionGoal(options: SessionGoalStoreOptions): Promise { + let removed = false; + const result = await patchSessionEntry({ + sessionKey: options.sessionKey, + storePath: options.storePath, + update: (entry) => { + if (!entry.goal) { + return null; + } + removed = true; + return { goal: undefined }; + }, + }); + return Boolean(result && removed); +} diff --git a/src/config/sessions/transcript.ts b/src/config/sessions/transcript.ts index e4cce311da75..0655c4bc2ac3 100644 --- a/src/config/sessions/transcript.ts +++ b/src/config/sessions/transcript.ts @@ -342,12 +342,17 @@ export async function appendExactAssistantMessageToSessionTranscript(params: { emitSessionTranscriptUpdate({ sessionFile, sessionKey, + ...(params.agentId ? { agentId: params.agentId } : {}), message: appendedMessage, messageId, }); break; case "file-only": - emitSessionTranscriptUpdate({ sessionFile, sessionKey }); + emitSessionTranscriptUpdate({ + sessionFile, + sessionKey, + ...(params.agentId ? { agentId: params.agentId } : {}), + }); break; case "none": break; diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index 89743fec73ed..733b32398420 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -201,6 +201,34 @@ export interface QuotaSuspension { state: LaneExecutionState; // State machine check for hot-path } +export type SessionGoalStatus = + | "active" + | "paused" + | "blocked" + | "usage_limited" + | "budget_limited" + | "complete"; + +export type SessionGoal = { + schemaVersion: 1; + id: string; + objective: string; + status: SessionGoalStatus; + createdAt: number; + updatedAt: number; + tokenStart: number; + tokenStartFresh?: boolean; + tokensUsed: number; + tokenBudget?: number; + continuationTurns: number; + lastStatusNote?: string; + pausedAt?: number; + blockedAt?: number; + completedAt?: number; + usageLimitedAt?: number; + budgetLimitedAt?: number; +}; + export type SessionEntry = { /** * Last delivered heartbeat payload (used to suppress duplicate heartbeat notifications). @@ -254,6 +282,8 @@ export type SessionEntry = { subagentRecovery?: SubagentRecoveryState; /** Quota cascade protection and state-aware failover status. */ quotaSuspension?: QuotaSuspension; + /** Core-owned durable goal state for this thread/session. */ + goal?: SessionGoal; /** Timestamp (ms) when the current sessionId first became active. */ sessionStartedAt?: number; /** Stable usage lineage key for transcript-backed rollups across sessionId rotations. */ diff --git a/src/gateway/chat-abort.test.ts b/src/gateway/chat-abort.test.ts index b26366874dde..c8586c1a3f51 100644 --- a/src/gateway/chat-abort.test.ts +++ b/src/gateway/chat-abort.test.ts @@ -11,6 +11,7 @@ import { type ChatAbortPayload = { runId: string; sessionKey: string; + agentId?: string; seq: number; state: "aborted"; stopReason?: string; @@ -190,6 +191,39 @@ describe("abortChatRunById", () => { expect(payload.message).toBeUndefined(); }); + it("fans out default-agent global aborts to scoped and legacy global subscribers", () => { + const runId = "run-main-global"; + const entry = { + ...createActiveEntry("global"), + agentId: "main", + }; + const ops = createOps({ runId, entry }); + ops.getRuntimeConfig = () => ({ agents: { list: [{ id: "main", default: true }] } }); + + const result = abortChatRunById(ops, { runId, sessionKey: "global" }); + + expect(result).toEqual({ aborted: true }); + const payload = firstBroadcastPayload(ops) as ChatAbortPayload; + expect(payload.agentId).toBe("main"); + expect(ops.nodeSendToSession).toHaveBeenCalledWith("agent:main:global", "chat", payload); + expect(ops.nodeSendToSession).toHaveBeenCalledWith("global", "chat", payload); + }); + + it("resolves unscoped global aborts to the default agent subscribers", () => { + const runId = "run-unscoped-global"; + const entry = createActiveEntry("global"); + const ops = createOps({ runId, entry }); + ops.getRuntimeConfig = () => ({ agents: { list: [{ id: "main", default: true }] } }); + + const result = abortChatRunById(ops, { runId, sessionKey: "global" }); + + expect(result).toEqual({ aborted: true }); + const payload = firstBroadcastPayload(ops) as ChatAbortPayload; + expect(payload.agentId).toBe("main"); + expect(ops.nodeSendToSession).toHaveBeenCalledWith("agent:main:global", "chat", payload); + expect(ops.nodeSendToSession).toHaveBeenCalledWith("global", "chat", payload); + }); + it("tags maintenance timeouts as timeout abort reasons", () => { const runId = "run-timeout"; const sessionKey = "main"; diff --git a/src/gateway/chat-abort.ts b/src/gateway/chat-abort.ts index fca6e01d4f56..e0d58c7fb9f5 100644 --- a/src/gateway/chat-abort.ts +++ b/src/gateway/chat-abort.ts @@ -1,4 +1,6 @@ +import { resolveDefaultAgentId } from "../agents/agent-scope-config.js"; import { isAbortRequestText } from "../auto-reply/reply/abort-primitives.js"; +import type { OpenClawConfig } from "../config/config.js"; import { emitAgentEvent } from "../infra/agent-events.js"; const DEFAULT_CHAT_RUN_ABORT_GRACE_MS = 60_000; @@ -7,6 +9,7 @@ export type ChatAbortControllerEntry = { controller: AbortController; sessionId: string; sessionKey: string; + agentId?: string; startedAtMs: number; expiresAtMs: number; ownerConnId?: string; @@ -90,6 +93,7 @@ export function registerChatAbortController(params: { runId: string; sessionId: string; sessionKey?: string | null; + agentId?: string; timeoutMs: number; ownerConnId?: string; ownerDeviceId?: string; @@ -116,6 +120,7 @@ export function registerChatAbortController(params: { controller, sessionId: params.sessionId, sessionKey: params.sessionKey, + agentId: normalizeActiveAgentId(params.agentId), startedAtMs: now, expiresAtMs: params.expiresAtMs ?? resolveChatRunExpiresAtMs({ now, timeoutMs: params.timeoutMs }), @@ -135,6 +140,11 @@ function normalizeProviderIdForActiveRun(providerId: string | undefined): string return trimmed || undefined; } +function normalizeActiveAgentId(agentId: string | undefined): string | undefined { + const trimmed = agentId?.trim().toLowerCase(); + return trimmed || undefined; +} + export type ChatAbortOps = { chatAbortControllers: Map; chatRunBuffers: Map; @@ -144,25 +154,55 @@ export type ChatAbortOps = { sessionId: string, clientRunId: string, sessionKey?: string, - ) => { sessionKey: string; clientRunId: string } | undefined; + ) => { sessionKey: string; agentId?: string; clientRunId: string } | undefined; agentRunSeq: Map; + getRuntimeConfig?: () => OpenClawConfig; broadcast: (event: string, payload: unknown, opts?: { dropIfSlow?: boolean }) => void; nodeSendToSession: (sessionKey: string, event: string, payload: unknown) => void; }; +function resolveChatAbortDeliverySessionKeys( + ops: ChatAbortOps, + sessionKey: string, + agentId: string | undefined, +): string[] { + if (sessionKey !== "global") { + return [sessionKey]; + } + const scopedAgentId = normalizeActiveAgentId(agentId); + if (!scopedAgentId) { + return [sessionKey]; + } + const keys = [`agent:${scopedAgentId}:global`]; + const cfg = ops.getRuntimeConfig?.(); + const defaultAgentId = cfg ? resolveDefaultAgentId(cfg) : undefined; + if (defaultAgentId && scopedAgentId === defaultAgentId) { + keys.push(sessionKey); + } + return keys; +} + function broadcastChatAborted( ops: ChatAbortOps, params: { runId: string; sessionKey: string; + agentId?: string; stopReason?: string; partialText?: string; }, ) { const { runId, sessionKey, stopReason, partialText } = params; + const defaultGlobalAgentId = + sessionKey === "global" ? normalizeActiveAgentId(resolveDefaultGlobalAgentId(ops)) : undefined; + const payloadAgentId = + sessionKey === "global" + ? (normalizeActiveAgentId(params.agentId) ?? defaultGlobalAgentId) + : normalizeActiveAgentId(params.agentId); const payload = { runId, sessionKey, + ...(payloadAgentId ? { agentId: payloadAgentId } : {}), seq: (ops.agentRunSeq.get(runId) ?? 0) + 1, state: "aborted" as const, stopReason, @@ -175,7 +215,18 @@ function broadcastChatAborted( : undefined, }; ops.broadcast("chat", payload); - ops.nodeSendToSession(sessionKey, "chat", payload); + for (const deliverySessionKey of resolveChatAbortDeliverySessionKeys( + ops, + sessionKey, + payloadAgentId, + )) { + ops.nodeSendToSession(deliverySessionKey, "chat", payload); + } +} + +function resolveDefaultGlobalAgentId(ops: ChatAbortOps): string | undefined { + const cfg = ops.getRuntimeConfig?.(); + return cfg ? resolveDefaultAgentId(cfg) : undefined; } export function abortChatRunById( @@ -205,10 +256,17 @@ export function abortChatRunById( ops.chatAbortControllers.delete(runId); ops.clearChatRunState(runId); const removed = ops.removeChatRun(runId, runId, sessionKey); - broadcastChatAborted(ops, { runId, sessionKey, stopReason, partialText }); + broadcastChatAborted(ops, { + runId, + sessionKey, + agentId: active.agentId, + stopReason, + partialText, + }); emitAgentEvent({ runId, sessionKey, + agentId: active.agentId, stream: "lifecycle", data: { phase: "end", diff --git a/src/gateway/local-request-context.ts b/src/gateway/local-request-context.ts index 088f69dcbe69..72218ba2b984 100644 --- a/src/gateway/local-request-context.ts +++ b/src/gateway/local-request-context.ts @@ -46,7 +46,7 @@ export function createLocalGatewayRequestContext( ): GatewayRequestContext { const logGateway = createSubsystemLogger("gateway/local"); const sessionEvents = new Set(); - const chatRuns = new Map(); + const chatRuns = new Map(); const chatRunBuffers: GatewayRequestContext["chatRunBuffers"] = new Map(); const chatDeltaSentAt: GatewayRequestContext["chatDeltaSentAt"] = new Map(); const chatDeltaLastBroadcastLen: GatewayRequestContext["chatDeltaLastBroadcastLen"] = new Map(); diff --git a/src/gateway/managed-image-attachments.test.ts b/src/gateway/managed-image-attachments.test.ts index 2f475ede012f..146a08b4cb75 100644 --- a/src/gateway/managed-image-attachments.test.ts +++ b/src/gateway/managed-image-attachments.test.ts @@ -16,6 +16,11 @@ const resolveOpenAiCompatibleHttpOperatorScopesMock = vi.fn(); const resolveOpenAiCompatibleHttpSenderIsOwnerMock = vi.fn(); const loadSessionEntryMock = vi.fn(); const readSessionMessagesMock = vi.fn(); +const getRuntimeConfigMock = vi.fn(() => ({})); + +vi.mock("../config/config.js", () => ({ + getRuntimeConfig: getRuntimeConfigMock, +})); vi.mock("./http-utils.js", () => ({ authorizeGatewayHttpRequestOrReply: authorizeGatewayHttpRequestOrReplyMock, @@ -92,7 +97,7 @@ function requireBlock(blocks: unknown[], index = 0): ManagedImageBlock { async function createFixture( stateDir: string, - options?: { sessionKey?: string; attachmentId?: string; filename?: string }, + options?: { sessionKey?: string; agentId?: string; attachmentId?: string; filename?: string }, ) { const attachmentId = options?.attachmentId ?? "11111111-1111-4111-8111-111111111111"; const sessionKey = options?.sessionKey ?? "agent:main:main"; @@ -103,6 +108,7 @@ async function createFixture( const record: Record = { attachmentId, sessionKey, + ...(options?.agentId ? { agentId: options.agentId } : {}), messageId: "msg-1", createdAt: new Date().toISOString(), alt: "Cat", @@ -895,6 +901,7 @@ describe("cleanupManagedOutgoingImageRecords", () => { beforeEach(async () => { stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "managed-image-cleanup-")); vi.clearAllMocks(); + getRuntimeConfigMock.mockReturnValue({}); }); afterEach(async () => { @@ -1012,4 +1019,84 @@ describe("cleanupManagedOutgoingImageRecords", () => { expect(result.retainedCount).toBe(1); await expect(fs.access(retainedFixture.originalPath)).resolves.toBeUndefined(); }); + + it("retains other selected-agent global records during scoped cleanup", async () => { + const retainedFixture = await createFixture(stateDir, { + sessionKey: "global", + agentId: "work", + attachmentId: "55555555-5555-4555-8555-555555555555", + }); + const deletedFixture = await createFixture(stateDir, { + sessionKey: "global", + agentId: "main", + attachmentId: "66666666-6666-4666-8666-666666666666", + }); + loadSessionEntryMock.mockReturnValue({ + storePath: path.join(stateDir, "gateway-sessions.json"), + entry: { sessionId: "sess-main-global", sessionFile: "/tmp/global-main.jsonl" }, + }); + readSessionMessagesMock.mockReturnValue([]); + + const result = await cleanupManagedOutgoingImageRecords({ + stateDir, + sessionKey: "global", + agentId: "main", + }); + + expect(loadSessionEntryMock).toHaveBeenCalledWith("global", { agentId: "main" }); + expect(result.deletedRecordCount).toBe(1); + expect(result.retainedCount).toBe(1); + await expect(fs.access(retainedFixture.originalPath)).resolves.toBeUndefined(); + await expectPathMissing(deletedFixture.originalPath); + }); + + it("treats legacy unscoped global records as the configured default agent", async () => { + getRuntimeConfigMock.mockReturnValue({ + agents: { list: [{ id: "main" }, { id: "work", default: true }] }, + }); + const deletedFixture = await createFixture(stateDir, { + sessionKey: "global", + attachmentId: "88888888-8888-4888-8888-888888888888", + }); + const retainedFixture = await createFixture(stateDir, { + sessionKey: "global", + agentId: "main", + attachmentId: "99999999-9999-4999-8999-999999999999", + }); + loadSessionEntryMock.mockReturnValue({ + storePath: path.join(stateDir, "gateway-sessions.json"), + entry: { sessionId: "sess-work-global", sessionFile: "/tmp/global-work.jsonl" }, + }); + readSessionMessagesMock.mockReturnValue([]); + + const result = await cleanupManagedOutgoingImageRecords({ + stateDir, + sessionKey: "global", + agentId: "work", + }); + + expect(result.deletedRecordCount).toBe(1); + expect(result.retainedCount).toBe(1); + await expectPathMissing(deletedFixture.originalPath); + await expect(fs.access(retainedFixture.originalPath)).resolves.toBeUndefined(); + }); + + it("does not retain selected-agent global records during full cleanup", async () => { + const fixture = await createFixture(stateDir, { + sessionKey: "global", + agentId: "work", + attachmentId: "77777777-7777-4777-8777-777777777777", + }); + loadSessionEntryMock.mockReturnValue({ + storePath: path.join(stateDir, "gateway-sessions.json"), + entry: { sessionId: "sess-work-global", sessionFile: "/tmp/global-work.jsonl" }, + }); + readSessionMessagesMock.mockReturnValue([]); + + const result = await cleanupManagedOutgoingImageRecords({ stateDir }); + + expect(result.deletedRecordCount).toBe(1); + expect(result.retainedCount).toBe(0); + await expectPathMissing(fixture.originalPath); + }); }); diff --git a/src/gateway/managed-image-attachments.ts b/src/gateway/managed-image-attachments.ts index e10241f9d352..4bd2d214d709 100644 --- a/src/gateway/managed-image-attachments.ts +++ b/src/gateway/managed-image-attachments.ts @@ -2,6 +2,8 @@ import { randomUUID } from "node:crypto"; import fs from "node:fs/promises"; import type { IncomingMessage, ServerResponse } from "node:http"; import path from "node:path"; +import { resolveDefaultAgentId } from "../agents/agent-scope-config.js"; +import { getRuntimeConfig } from "../config/config.js"; import { resolveStateDir } from "../config/paths.js"; import { readLocalFileSafely } from "../infra/fs-safe.js"; import { tryReadJson, writeJson } from "../infra/json-files.js"; @@ -65,6 +67,7 @@ type ManagedImageRetentionClass = "transient" | "history"; type ManagedImageRecord = { attachmentId: string; sessionKey: string; + agentId?: string; messageId: string | null; createdAt: string; updatedAt?: string; @@ -101,6 +104,13 @@ const sessionManagedOutgoingAttachmentIndexCache = new Map< >(); const MAX_SESSION_MANAGED_OUTGOING_ATTACHMENT_INDEX_CACHE_ENTRIES = 500; +function buildSessionManagedOutgoingAttachmentIndexCacheKey( + sessionKey: string, + agentId?: string, +): string { + return sessionKey === "global" && agentId ? `agent:${agentId}:global` : sessionKey; +} + export function resolveManagedImageAttachmentLimits( config?: ManagedImageAttachmentLimitsConfig | null, ): ManagedImageAttachmentLimits { @@ -391,12 +401,16 @@ export async function cleanupManagedOutgoingImageRecords(params?: { nowMs?: number; transientMaxAgeMs?: number; sessionKey?: string; + agentId?: string; forceDeleteSessionRecords?: boolean; }): Promise { const stateDir = params?.stateDir ?? resolveStateDir(); const nowMs = params?.nowMs ?? Date.now(); const transientMaxAgeMs = params?.transientMaxAgeMs ?? DEFAULT_TRANSIENT_OUTGOING_IMAGE_TTL_MS; const sessionKeyFilter = params?.sessionKey ?? null; + const agentIdFilter = params?.agentId?.trim() || undefined; + const defaultAgentId = + sessionKeyFilter === "global" ? resolveDefaultAgentId(getRuntimeConfig()) : undefined; const forceDeleteSessionRecords = params?.forceDeleteSessionRecords === true; const recordsDir = resolveOutgoingRecordsDir(stateDir); let names: string[] = []; @@ -436,6 +450,19 @@ export async function cleanupManagedOutgoingImageRecords(params?: { retainedCount += 1; continue; } + if ( + sessionKeyFilter === "global" && + record.sessionKey === "global" && + ((agentIdFilter && + resolveManagedImageRecordAgentId(record, defaultAgentId) !== agentIdFilter) || + (!agentIdFilter && typeof record.agentId === "string" && record.agentId.trim())) + ) { + if (record.original?.path) { + retainedReferencedPaths.add(record.original.path); + } + retainedCount += 1; + continue; + } let shouldDelete = false; if ( @@ -472,6 +499,14 @@ export async function cleanupManagedOutgoingImageRecords(params?: { return { deletedRecordCount, deletedFileCount, retainedCount }; } +function resolveManagedImageRecordAgentId( + record: ManagedImageRecord, + defaultAgentId: string | undefined, +): string | undefined { + const explicitAgentId = record.agentId?.trim(); + return explicitAgentId || defaultAgentId; +} + async function readManagedImageRecord( attachmentId: string, stateDir = resolveStateDir(), @@ -577,9 +612,11 @@ function collectManagedOutgoingAttachmentRefs( function getCachedSessionManagedOutgoingAttachmentIndex( sessionKey: string, + agentId: string | undefined, stat: { transcriptPath: string; mtimeMs: number; size: number }, ) { - const cached = sessionManagedOutgoingAttachmentIndexCache.get(sessionKey); + const cacheKey = buildSessionManagedOutgoingAttachmentIndexCacheKey(sessionKey, agentId); + const cached = sessionManagedOutgoingAttachmentIndexCache.get(cacheKey); if (!cached) { return null; } @@ -588,25 +625,29 @@ function getCachedSessionManagedOutgoingAttachmentIndex( cached.mtimeMs !== stat.mtimeMs || cached.size !== stat.size ) { - sessionManagedOutgoingAttachmentIndexCache.delete(sessionKey); + sessionManagedOutgoingAttachmentIndexCache.delete(cacheKey); return null; } - sessionManagedOutgoingAttachmentIndexCache.delete(sessionKey); - sessionManagedOutgoingAttachmentIndexCache.set(sessionKey, cached); + sessionManagedOutgoingAttachmentIndexCache.delete(cacheKey); + sessionManagedOutgoingAttachmentIndexCache.set(cacheKey, cached); return cached.index; } function setCachedSessionManagedOutgoingAttachmentIndex( sessionKey: string, + agentId: string | undefined, stat: { transcriptPath: string; mtimeMs: number; size: number }, index: SessionManagedOutgoingAttachmentIndex, ) { - sessionManagedOutgoingAttachmentIndexCache.set(sessionKey, { - transcriptPath: stat.transcriptPath, - mtimeMs: stat.mtimeMs, - size: stat.size, - index, - }); + sessionManagedOutgoingAttachmentIndexCache.set( + buildSessionManagedOutgoingAttachmentIndexCacheKey(sessionKey, agentId), + { + transcriptPath: stat.transcriptPath, + mtimeMs: stat.mtimeMs, + size: stat.size, + index, + }, + ); while ( sessionManagedOutgoingAttachmentIndexCache.size > MAX_SESSION_MANAGED_OUTGOING_ATTACHMENT_INDEX_CACHE_ENTRIES @@ -622,14 +663,19 @@ function setCachedSessionManagedOutgoingAttachmentIndex( async function getSessionManagedOutgoingAttachmentIndex( sessionKey: string, cache?: Map, + agentId?: string, ) { - if (cache?.has(sessionKey)) { - return cache.get(sessionKey) ?? null; + const cacheKey = buildSessionManagedOutgoingAttachmentIndexCacheKey(sessionKey, agentId); + if (cache?.has(cacheKey)) { + return cache.get(cacheKey) ?? null; } - const { storePath, entry } = loadSessionEntry(sessionKey); + const { storePath, entry } = loadSessionEntry( + sessionKey, + sessionKey === "global" && agentId ? { agentId } : undefined, + ); const sessionId = entry?.sessionId; if (!sessionId) { - cache?.set(sessionKey, null); + cache?.set(cacheKey, null); return null; } @@ -645,14 +691,15 @@ async function getSessionManagedOutgoingAttachmentIndex( }; const cachedIndex = getCachedSessionManagedOutgoingAttachmentIndex( sessionKey, + agentId, transcriptStat, ); if (cachedIndex) { - cache?.set(sessionKey, cachedIndex); + cache?.set(cacheKey, cachedIndex); return cachedIndex; } } catch { - sessionManagedOutgoingAttachmentIndexCache.delete(sessionKey); + sessionManagedOutgoingAttachmentIndexCache.delete(cacheKey); } } @@ -678,9 +725,9 @@ async function getSessionManagedOutgoingAttachmentIndex( } if (transcriptStat) { - setCachedSessionManagedOutgoingAttachmentIndex(sessionKey, transcriptStat, index); + setCachedSessionManagedOutgoingAttachmentIndex(sessionKey, agentId, transcriptStat, index); } - cache?.set(sessionKey, index); + cache?.set(cacheKey, index); return index; } @@ -691,7 +738,11 @@ async function recordMatchesTranscriptMessage( if (!record.messageId) { return false; } - const index = await getSessionManagedOutgoingAttachmentIndex(record.sessionKey, cache); + const index = await getSessionManagedOutgoingAttachmentIndex( + record.sessionKey, + cache, + record.agentId, + ); return ( index?.has(buildManagedOutgoingAttachmentRefKey(record.messageId, record.attachmentId)) ?? false ); @@ -734,6 +785,7 @@ export async function attachManagedOutgoingImagesToMessage(params: { export async function createManagedOutgoingImageBlocks(params: { sessionKey: string; + agentId?: string; mediaUrls?: string[] | null; stateDir?: string; messageId?: string | null; @@ -870,6 +922,9 @@ export async function createManagedOutgoingImageBlocks(params: { const record: ManagedImageRecord = { attachmentId: randomUUID(), sessionKey, + ...(sessionKey === "global" && params.agentId?.trim() + ? { agentId: params.agentId.trim() } + : {}), messageId: params.messageId ?? null, createdAt: new Date().toISOString(), retentionClass: params.messageId ? "history" : "transient", diff --git a/src/gateway/server-chat-state.ts b/src/gateway/server-chat-state.ts index fb0930222fb6..383413307087 100644 --- a/src/gateway/server-chat-state.ts +++ b/src/gateway/server-chat-state.ts @@ -2,11 +2,13 @@ import type { AgentEventPayload } from "../infra/agent-events.js"; export type ChatRunEntry = { sessionKey: string; + agentId?: string; clientRunId: string; }; export type BufferedAgentEvent = { sessionKey?: string; + agentId?: string; payload: AgentEventPayload & { spawnedBy?: string }; }; diff --git a/src/gateway/server-chat.agent-events.test.ts b/src/gateway/server-chat.agent-events.test.ts index ff121be96692..861a59085503 100644 --- a/src/gateway/server-chat.agent-events.test.ts +++ b/src/gateway/server-chat.agent-events.test.ts @@ -1529,6 +1529,57 @@ describe("agent event handler", () => { resetAgentRunContextForTest(); }); + it("loads selected-agent global session snapshots for tool events", () => { + const { broadcastToConnIds, chatRunState, sessionEventSubscribers, handler } = createHarness(); + vi.mocked(loadGatewaySessionRow).mockReturnValue({ + key: "global", + kind: "global", + model: "work-model", + goal: { + schemaVersion: 1, + id: "goal-work", + objective: "ship scoped goals", + status: "active", + createdAt: 1_000, + updatedAt: 1_100, + tokenStart: 0, + tokensUsed: 0, + continuationTurns: 0, + }, + status: "running", + updatedAt: 1_200, + }); + chatRunState.registry.add("run-global-tool", { + sessionKey: "global", + agentId: "work", + clientRunId: "client-global-tool", + }); + sessionEventSubscribers.subscribe("conn-session"); + + handler({ + runId: "run-global-tool", + seq: 1, + stream: "tool", + ts: 1_234, + data: { phase: "start", name: "exec", toolCallId: "tool-global-1" }, + }); + + expect(loadGatewaySessionRow).toHaveBeenCalledWith("global", { agentId: "work" }); + expect(requireMockArg(broadcastToConnIds, 0, 0, "session tool event")).toBe("session.tool"); + expect(requireMockPayload(broadcastToConnIds, 0, 1, "session tool payload")).toEqual( + expect.objectContaining({ + sessionKey: "global", + agentId: "work", + model: "work-model", + goal: expect.objectContaining({ + objective: "ship scoped goals", + status: "active", + }), + status: "running", + }), + ); + }); + it("does not duplicate tool events to clients subscribed by run and session", () => { const { broadcastToConnIds, sessionEventSubscribers, toolEventRecipients, handler } = createHarness({ @@ -2040,6 +2091,47 @@ describe("agent event handler", () => { }); }); + it("omits goal state from unscoped global lifecycle snapshots", () => { + vi.mocked(loadGatewaySessionRow).mockReturnValue({ + key: "global", + kind: "global", + updatedAt: 1_650, + status: "running", + goal: { + schemaVersion: 1, + id: "goal-default", + objective: "Wrong agent goal", + status: "active", + createdAt: 1, + updatedAt: 2, + tokenStart: 0, + tokensUsed: 42, + continuationTurns: 0, + }, + }); + + const { broadcastToConnIds, sessionEventSubscribers, handler } = createHarness({ + resolveSessionKeyForRun: () => "global", + }); + + sessionEventSubscribers.subscribe("conn-session"); + + handler({ + runId: "run-global", + seq: 2, + stream: "lifecycle", + ts: 1_800, + data: { phase: "end", endedAt: 1_700 }, + }); + + const payload = requireRecord( + requireMockArg(broadcastToConnIds, 0, 1, "sessions changed payload"), + "sessions changed payload", + ); + expect(payload).not.toHaveProperty("goal"); + expect(requireRecord(payload.session, "nested session")).not.toHaveProperty("goal"); + }); + it("keeps tool output for Control UI recipients when verbose is on", () => { const { broadcastToConnIds, toolEventRecipients, handler } = createHarness({ resolveSessionKeyForRun: () => "session-1", @@ -2141,6 +2233,139 @@ describe("agent event handler", () => { expect(nodePayload.runId).toBe("run-fallback-client"); }); + it("keeps selected-agent global chat events scoped to the linked agent", () => { + const { broadcast, nodeSendToSession, chatRunState, handler } = createHarness(); + chatRunState.registry.add("run-global-main", { + sessionKey: "global", + agentId: "main", + clientRunId: "client-global-main", + }); + + handler({ + runId: "run-global-main", + seq: 1, + stream: "assistant", + ts: Date.now(), + data: { text: "main global reply" }, + }); + + const chatPayload = chatBroadcastCalls(broadcast)[0]?.[1] as { + agentId?: string; + sessionKey?: string; + }; + expect(chatPayload).toEqual( + expect.objectContaining({ + agentId: "main", + sessionKey: "global", + }), + ); + const nodeCalls = sessionChatCalls(nodeSendToSession); + expect(nodeCalls[0]?.[0]).toBe("agent:main:global"); + expect(nodeCalls.map(([sessionKey]) => sessionKey)).toContain("global"); + }); + + it("persists selected-agent global lifecycle state with the linked agent", () => { + const { broadcastToConnIds, chatRunState, handler, sessionEventSubscribers } = createHarness(); + sessionEventSubscribers.subscribe("conn-1"); + chatRunState.registry.add("run-global-work", { + sessionKey: "global", + agentId: "work", + clientRunId: "client-global-work", + }); + + handler({ + runId: "run-global-work", + seq: 1, + stream: "lifecycle", + ts: Date.now(), + data: { phase: "start" }, + }); + + expect(persistGatewaySessionLifecycleEventMock).toHaveBeenCalledWith( + expect.objectContaining({ + sessionKey: "global", + agentId: "work", + }), + ); + expect(loadGatewaySessionRow).toHaveBeenCalledWith("global", { agentId: "work" }); + expect(broadcastToConnIds).toHaveBeenCalledWith( + "sessions.changed", + expect.objectContaining({ + sessionKey: "global", + agentId: "work", + }), + new Set(["conn-1"]), + { dropIfSlow: true }, + ); + }); + + it("routes hidden selected-agent global chat events only to matching subscribers", () => { + const { broadcastToConnIds, chatRunState, handler, sessionMessageSubscribers } = + createHarness(); + sessionMessageSubscribers.subscribe("conn-main", "agent:main:global"); + sessionMessageSubscribers.subscribe("conn-work", "agent:work:global"); + chatRunState.registry.add("run-hidden-main", { + sessionKey: "global", + agentId: "main", + clientRunId: "client-hidden-main", + }); + registerAgentRunContext("run-hidden-main", { + sessionKey: "global", + isControlUiVisible: false, + }); + + handler({ + runId: "run-hidden-main", + seq: 1, + stream: "assistant", + ts: Date.now(), + data: { text: "hidden main global reply" }, + }); + + const chatCall = broadcastToConnIds.mock.calls.find(([event]) => event === "chat"); + expect(chatCall?.[2]).toEqual(new Set(["conn-main"])); + expect(chatCall?.[1]).toEqual( + expect.objectContaining({ + agentId: "main", + sessionKey: "global", + }), + ); + }); + + it("routes hidden bare global chat events to the configured default agent subscriber", () => { + vi.mocked(getRuntimeConfig).mockReturnValue({ + agents: { list: [{ id: "main" }, { id: "ops", default: true }] }, + }); + const { broadcastToConnIds, chatRunState, handler, sessionMessageSubscribers } = + createHarness(); + sessionMessageSubscribers.subscribe("conn-main", "agent:main:global"); + sessionMessageSubscribers.subscribe("conn-ops", "agent:ops:global"); + chatRunState.registry.add("run-hidden-default", { + sessionKey: "global", + clientRunId: "client-hidden-default", + }); + registerAgentRunContext("run-hidden-default", { + sessionKey: "global", + isControlUiVisible: false, + }); + + handler({ + runId: "run-hidden-default", + seq: 1, + stream: "assistant", + ts: Date.now(), + data: { text: "hidden default global reply" }, + }); + + const chatCall = broadcastToConnIds.mock.calls.find(([event]) => event === "chat"); + expect(chatCall?.[2]).toEqual(new Set(["conn-ops"])); + expect(chatCall?.[1]).toEqual( + expect.objectContaining({ + sessionKey: "global", + }), + ); + }); + it("keeps chat-linked run remapping alive across per-attempt lifecycle errors", () => { vi.useFakeTimers(); const { broadcast, chatRunState, clearAgentRunContext, agentRunSeq, handler } = createHarness({ diff --git a/src/gateway/server-chat.ts b/src/gateway/server-chat.ts index a26d3b6b5dfe..481e12dddf15 100644 --- a/src/gateway/server-chat.ts +++ b/src/gateway/server-chat.ts @@ -1,3 +1,4 @@ +import { resolveDefaultAgentId } from "../agents/agent-scope.js"; import { resolveToolSearchCodeDisplayTarget } from "../agents/tool-display-common.js"; import { DEFAULT_HEARTBEAT_ACK_MAX_CHARS, stripHeartbeatToken } from "../auto-reply/heartbeat.js"; import { normalizeVerboseLevel } from "../auto-reply/thinking.js"; @@ -312,8 +313,13 @@ export function createAgentEventHandler({ return result; }; - const buildSessionEventSnapshot = (sessionKey: string, evt?: AgentEventPayload) => { - const row = loadGatewaySessionRowForSnapshot(sessionKey); + const buildSessionEventSnapshot = ( + sessionKey: string, + evt?: AgentEventPayload, + agentId?: string, + ) => { + const row = loadGatewaySessionRowForSnapshot(sessionKey, agentId ? { agentId } : undefined); + const omitUnscopedGlobalGoal = sessionKey === "global" && !agentId; const lifecyclePatch = evt ? deriveGatewaySessionLifecycleSnapshot({ session: row @@ -330,6 +336,9 @@ export function createAgentEventHandler({ }) : {}; const session = row ? { ...row, ...lifecyclePatch } : undefined; + if (session && omitUnscopedGlobalGoal) { + delete session.goal; + } const snapshotSource = session ?? lifecyclePatch; return { ...(session ? { session } : {}), @@ -370,6 +379,7 @@ export function createAgentEventHandler({ lastThreadId: row?.lastThreadId, totalTokens: row?.totalTokens, totalTokensFresh: row?.totalTokensFresh, + ...(omitUnscopedGlobalGoal ? {} : { goal: row?.goal ?? null }), contextTokens: row?.contextTokens, estimatedCostUsd: row?.estimatedCostUsd, responseUsage: row?.responseUsage, @@ -383,6 +393,36 @@ export function createAgentEventHandler({ }; }; + const resolveSessionDeliveryKey = (sessionKey: string, agentId?: string) => { + if (sessionKey !== "global") { + return sessionKey; + } + const scopedAgentId = agentId ?? resolveDefaultAgentId(getRuntimeConfig()); + return `agent:${scopedAgentId}:global`; + }; + const resolveNodeSessionDeliveryKeys = (sessionKey: string, agentId?: string) => { + if (sessionKey !== "global") { + return [sessionKey]; + } + const defaultAgentId = resolveDefaultAgentId(getRuntimeConfig()); + const scopedAgentId = agentId ?? defaultAgentId; + const keys = [`agent:${scopedAgentId}:global`]; + if (scopedAgentId === defaultAgentId) { + keys.push("global"); + } + return keys; + }; + const sendNodeSessionPayloadForAgent = ( + sessionKey: string, + event: string, + payload: unknown, + agentId?: string, + ) => { + for (const deliverySessionKey of resolveNodeSessionDeliveryKeys(sessionKey, agentId)) { + nodeSendToSession(deliverySessionKey, event, payload); + } + }; + const finalizeLifecycleEvent = (evt: AgentEventPayload, opts?: TerminalLifecycleOptions) => { const lifecyclePhase = evt.stream === "lifecycle" && typeof evt.data?.phase === "string" ? evt.data.phase : null; @@ -393,6 +433,7 @@ export function createAgentEventHandler({ clearPendingTerminalLifecycleError(evt.runId); const chatLink = chatRunState.registry.peek(evt.runId); + const sessionAgentId = chatLink?.agentId ?? evt.agentId; const eventSessionKey = typeof evt.sessionKey === "string" && evt.sessionKey.trim() ? evt.sessionKey : undefined; const isControlUiVisible = getAgentRunContext(evt.runId)?.isControlUiVisible ?? true; @@ -402,8 +443,15 @@ export function createAgentEventHandler({ const eventRunId = chatLink?.clientRunId ?? evt.runId; const isAborted = chatRunState.abortedRuns.has(clientRunId) || chatRunState.abortedRuns.has(evt.runId); + const deliverySessionKey = sessionKey + ? resolveSessionDeliveryKey(sessionKey, sessionAgentId) + : undefined; - if (sessionKey && (isControlUiVisible || sessionMessageSubscribers.get(sessionKey).size > 0)) { + if ( + sessionKey && + (isControlUiVisible || + (deliverySessionKey ? sessionMessageSubscribers.get(deliverySessionKey).size > 0 : false)) + ) { if (!isAborted) { const evtStopReason = typeof evt.data?.stopReason === "string" ? evt.data.stopReason : undefined; @@ -425,7 +473,7 @@ export function createAgentEventHandler({ evt.data?.error, evtStopReason, evtErrorKind, - { controlUiVisible: isControlUiVisible }, + { agentId: finished.agentId, controlUiVisible: isControlUiVisible }, ); } } else if (!(opts?.skipChatErrorFinal && lifecyclePhase === "error")) { @@ -438,7 +486,7 @@ export function createAgentEventHandler({ evt.data?.error, evtStopReason, evtErrorKind, - { controlUiVisible: isControlUiVisible }, + { agentId: sessionAgentId, controlUiVisible: isControlUiVisible }, ); } } else { @@ -457,18 +505,23 @@ export function createAgentEventHandler({ if (sessionKey) { clearTrackedActiveRun?.({ runId: evt.runId, clientRunId, sessionKey }); - void persistGatewaySessionLifecycleEvent({ sessionKey, event: evt }).catch(() => undefined); + void persistGatewaySessionLifecycleEvent({ + sessionKey, + agentId: sessionAgentId, + event: evt, + }).catch(() => undefined); const sessionEventConnIds = sessionEventSubscribers.getAll(); if (sessionEventConnIds.size > 0) { broadcastToConnIds( "sessions.changed", { sessionKey, + ...(sessionAgentId ? { agentId: sessionAgentId } : {}), phase: lifecyclePhase, runId: evt.runId, ...(eventRunId !== evt.runId ? { clientRunId: eventRunId } : {}), ts: evt.ts, - ...buildSessionEventSnapshot(sessionKey, evt), + ...buildSessionEventSnapshot(sessionKey, evt, sessionAgentId), }, sessionEventConnIds, { dropIfSlow: true }, @@ -496,6 +549,7 @@ export function createAgentEventHandler({ const emitChatDelta = ( sessionKey: string, + agentId: string | undefined, clientRunId: string, sourceRunId: string, seq: number, @@ -543,6 +597,7 @@ export function createAgentEventHandler({ const payload = { runId: clientRunId, sessionKey, + ...(agentId ? { agentId } : {}), ...(spawnedBy && { spawnedBy }), seq, state: "delta" as const, @@ -555,6 +610,7 @@ export function createAgentEventHandler({ }, }; sendChatPayload(sessionKey, payload, { + agentId, controlUiVisible: opts?.controlUiVisible ?? true, dropIfSlow: true, }); @@ -584,6 +640,7 @@ export function createAgentEventHandler({ const flushBufferedChatDeltaIfNeeded = ( sessionKey: string, + agentId: string | undefined, clientRunId: string, sourceRunId: string, seq: number, @@ -612,6 +669,7 @@ export function createAgentEventHandler({ const flushPayload = { runId: clientRunId, sessionKey, + ...(agentId ? { agentId } : {}), ...(spawnedBy && { spawnedBy }), seq, state: "delta" as const, @@ -624,6 +682,7 @@ export function createAgentEventHandler({ }, }; sendChatPayload(sessionKey, flushPayload, { + agentId, controlUiVisible: opts?.controlUiVisible ?? true, dropIfSlow: true, }); @@ -635,14 +694,15 @@ export function createAgentEventHandler({ const sendChatPayload = ( sessionKey: string, payload: unknown, - opts?: { controlUiVisible?: boolean; dropIfSlow?: boolean }, + opts?: { agentId?: string; controlUiVisible?: boolean; dropIfSlow?: boolean }, ) => { + const deliverySessionKey = resolveSessionDeliveryKey(sessionKey, opts?.agentId); if (opts?.controlUiVisible ?? true) { broadcast("chat", payload, { dropIfSlow: opts?.dropIfSlow }); - nodeSendToSession(sessionKey, "chat", payload); + sendNodeSessionPayloadForAgent(sessionKey, "chat", payload, opts?.agentId); return; } - const recipients = sessionMessageSubscribers.get(sessionKey); + const recipients = sessionMessageSubscribers.get(deliverySessionKey); if (recipients.size > 0) { broadcastToConnIds("chat", payload, recipients, { dropIfSlow: opts?.dropIfSlow }); } @@ -657,7 +717,7 @@ export function createAgentEventHandler({ error?: unknown, stopReason?: string, errorKind?: ErrorKind, - opts?: { controlUiVisible?: boolean }, + opts?: { agentId?: string; controlUiVisible?: boolean }, ) => { const { text, shouldSuppressSilent } = resolveBufferedChatTextState(clientRunId, sourceRunId, { suppressLeadFragments: false, @@ -666,13 +726,14 @@ export function createAgentEventHandler({ // before the final event. The 150 ms throttle in emitChatDelta may have // suppressed the most recent chunk, leaving the client with stale text. // Only flush if the buffered text differs from the last broadcast to avoid duplicates. - flushBufferedChatDeltaIfNeeded(sessionKey, clientRunId, sourceRunId, seq, opts); + flushBufferedChatDeltaIfNeeded(sessionKey, opts?.agentId, clientRunId, sourceRunId, seq, opts); chatRunState.clearRun(clientRunId); const spawnedBy = resolveSpawnedBy(sessionKey); if (jobState === "done") { const payload = { runId: clientRunId, sessionKey, + ...(opts?.agentId ? { agentId: opts.agentId } : {}), ...(spawnedBy && { spawnedBy }), seq, state: "final" as const, @@ -692,6 +753,7 @@ export function createAgentEventHandler({ const payload = { runId: clientRunId, sessionKey, + ...(opts?.agentId ? { agentId: opts.agentId } : {}), ...(spawnedBy && { spawnedBy }), seq, state: "error" as const, @@ -704,19 +766,21 @@ export function createAgentEventHandler({ const sendAgentPayload = ( sessionKey: string | undefined, payload: AgentEventPayload & { spawnedBy?: string }, - opts?: { controlUiVisible?: boolean; dropIfSlow?: boolean }, + opts?: { agentId?: string; controlUiVisible?: boolean; dropIfSlow?: boolean }, ) => { if (opts?.controlUiVisible ?? true) { broadcast("agent", payload); if (sessionKey) { - nodeSendToSession(sessionKey, "agent", payload); + sendNodeSessionPayloadForAgent(sessionKey, "agent", payload, opts?.agentId); } return; } if (!sessionKey) { return; } - const recipients = sessionMessageSubscribers.get(sessionKey); + const recipients = sessionMessageSubscribers.get( + resolveSessionDeliveryKey(sessionKey, opts?.agentId), + ); if (recipients.size > 0) { broadcastToConnIds("agent", payload, recipients, { dropIfSlow: opts?.dropIfSlow }); } @@ -725,9 +789,10 @@ export function createAgentEventHandler({ const sendNodeAgentPayload = ( sessionKey: string | undefined, payload: AgentEventPayload & { spawnedBy?: string }, + agentId?: string, ) => { if (sessionKey) { - nodeSendToSession(sessionKey, "agent", payload); + sendNodeSessionPayloadForAgent(sessionKey, "agent", payload, agentId); } }; @@ -747,7 +812,7 @@ export function createAgentEventHandler({ }); bufferedEntries.sort((a, b) => a.buffered.payload.seq - b.buffered.payload.seq); for (const { key, buffered } of bufferedEntries) { - sendAgentPayload(buffered.sessionKey, buffered.payload); + sendAgentPayload(buffered.sessionKey, buffered.payload, { agentId: buffered.agentId }); chatRunState.bufferedAgentEvents.delete(key); chatRunState.agentDeltaSentAt.set(key, Date.now()); } @@ -784,8 +849,9 @@ export function createAgentEventHandler({ const buildBufferedAgentEvent = ( sessionKey: string | undefined, + agentId: string | undefined, payload: AgentEventPayload & { spawnedBy?: string }, - ): BufferedAgentEvent => (sessionKey ? { sessionKey, payload } : { payload }); + ): BufferedAgentEvent => (sessionKey ? { sessionKey, agentId, payload } : { agentId, payload }); const mergeBufferedAgentPayload = ( previous: BufferedAgentEvent, @@ -814,18 +880,19 @@ export function createAgentEventHandler({ const sendOrBufferAgentTextEvent = ( clientRunId: string, sessionKey: string | undefined, + agentId: string | undefined, payload: AgentEventPayload & { spawnedBy?: string }, ) => { const stream = resolveAgentTextThrottleStream(payload); if (!stream) { - sendAgentPayload(sessionKey, payload); + sendAgentPayload(sessionKey, payload, { agentId }); return; } const now = Date.now(); const key = agentTextThrottleKey(clientRunId, stream); const last = chatRunState.agentDeltaSentAt.get(key); if (last !== undefined && now - last < 150) { - const nextBuffered = buildBufferedAgentEvent(sessionKey, payload); + const nextBuffered = buildBufferedAgentEvent(sessionKey, agentId, payload); const buffered = chatRunState.bufferedAgentEvents.get(key); chatRunState.bufferedAgentEvents.set( key, @@ -834,7 +901,7 @@ export function createAgentEventHandler({ return; } flushBufferedAgentDeltaIfNeeded(clientRunId); - sendAgentPayload(sessionKey, payload); + sendAgentPayload(sessionKey, payload, { agentId }); chatRunState.agentDeltaSentAt.set(key, now); }; @@ -873,6 +940,7 @@ export function createAgentEventHandler({ } const chatLink = chatRunState.registry.peek(evt.runId); + const sessionAgentId = chatLink?.agentId ?? evt.agentId; const eventSessionKey = typeof evt.sessionKey === "string" && evt.sessionKey.trim() ? evt.sessionKey : undefined; const runContext = getAgentRunContext(evt.runId); @@ -891,6 +959,7 @@ export function createAgentEventHandler({ ? { ...eventForClients, sessionKey, + ...(sessionAgentId ? { agentId: sessionAgentId } : {}), ...(spawnedBy && { spawnedBy }), ...(isHeartbeat !== undefined && { isHeartbeat }), } @@ -899,7 +968,8 @@ export function createAgentEventHandler({ ...(isHeartbeat !== undefined && { isHeartbeat }), }; const hasSessionMessageSubscribers = sessionKey - ? sessionMessageSubscribers.get(sessionKey).size > 0 + ? sessionMessageSubscribers.get(resolveSessionDeliveryKey(sessionKey, sessionAgentId)).size > + 0 : false; const last = agentRunSeq.get(evt.runId) ?? 0; const isToolEvent = evt.stream === "tool"; @@ -947,9 +1017,16 @@ export function createAgentEventHandler({ !isAborted && !suppressHeartbeatToolEvents ) { - flushBufferedChatDeltaIfNeeded(sessionKey, clientRunId, evt.runId, evt.seq, { - controlUiVisible: isControlUiVisible, - }); + flushBufferedChatDeltaIfNeeded( + sessionKey, + sessionAgentId, + clientRunId, + evt.runId, + evt.seq, + { + controlUiVisible: isControlUiVisible, + }, + ); flushBufferedAgentDeltaIfNeeded(clientRunId); } // Always broadcast tool events to registered WS recipients with @@ -965,15 +1042,20 @@ export function createAgentEventHandler({ ) { broadcastToConnIds( "agent", - sessionKey ? { ...agentPayload, ...buildSessionEventSnapshot(sessionKey) } : agentPayload, + sessionKey + ? { + ...agentPayload, + ...buildSessionEventSnapshot(sessionKey, undefined, sessionAgentId), + } + : agentPayload, runToolRecipients, ); } if (!isControlUiVisible && sessionKey && !suppressHeartbeatToolEvents) { sendAgentPayload( sessionKey, - { ...agentPayload, ...buildSessionEventSnapshot(sessionKey) }, - { controlUiVisible: false, dropIfSlow: true }, + { ...agentPayload, ...buildSessionEventSnapshot(sessionKey, undefined, sessionAgentId) }, + { agentId: sessionAgentId, controlUiVisible: false, dropIfSlow: true }, ); } // Session subscribers power operator UIs that attach to an existing @@ -989,7 +1071,10 @@ export function createAgentEventHandler({ if (sessionSubscribers.size > 0) { broadcastToConnIds( "session.tool", - { ...agentPayload, ...buildSessionEventSnapshot(sessionKey) }, + { + ...agentPayload, + ...buildSessionEventSnapshot(sessionKey, undefined, sessionAgentId), + }, sessionSubscribers, { dropIfSlow: true }, ); @@ -1003,18 +1088,28 @@ export function createAgentEventHandler({ !isAborted ) { if (sessionKey) { - flushBufferedChatDeltaIfNeeded(sessionKey, clientRunId, evt.runId, evt.seq, { - controlUiVisible: isControlUiVisible, - }); + flushBufferedChatDeltaIfNeeded( + sessionKey, + sessionAgentId, + clientRunId, + evt.runId, + evt.seq, + { + controlUiVisible: isControlUiVisible, + }, + ); } flushBufferedAgentDeltaIfNeeded(clientRunId); } if (isControlUiVisible) { if (shouldCoalesceAgentEvent) { - sendOrBufferAgentTextEvent(clientRunId, sessionKey, agentPayload); + sendOrBufferAgentTextEvent(clientRunId, sessionKey, sessionAgentId, agentPayload); } else { flushBufferedAgentDeltaIfNeeded(clientRunId); - sendAgentPayload(sessionKey, agentPayload, { controlUiVisible: isControlUiVisible }); + sendAgentPayload(sessionKey, agentPayload, { + agentId: sessionAgentId, + controlUiVisible: isControlUiVisible, + }); const textThrottleStream = resolveAgentTextThrottleStream(evt); if (textThrottleStream && shouldAdvanceAgentTextThrottle(evt)) { chatRunState.agentDeltaSentAt.set( @@ -1039,8 +1134,9 @@ export function createAgentEventHandler({ sessionKey, projectToolSearchCodeEventForChannelPayload({ ...channelToolPayload, - ...buildSessionEventSnapshot(sessionKey), + ...buildSessionEventSnapshot(sessionKey, undefined, sessionAgentId), }), + sessionAgentId, ); } if ( @@ -1049,9 +1145,18 @@ export function createAgentEventHandler({ typeof evt.data?.text === "string" && !shouldSuppressAssistantEventForLiveChat(evt.data) ) { - emitChatDelta(sessionKey, clientRunId, evt.runId, evt.seq, evt.data.text, evt.data.delta, { - controlUiVisible: isControlUiVisible, - }); + emitChatDelta( + sessionKey, + sessionAgentId, + clientRunId, + evt.runId, + evt.seq, + evt.data.text, + evt.data.delta, + { + controlUiVisible: isControlUiVisible, + }, + ); } } @@ -1072,18 +1177,23 @@ export function createAgentEventHandler({ } if (sessionKey && lifecyclePhase === "start") { - void persistGatewaySessionLifecycleEvent({ sessionKey, event: evt }).catch(() => undefined); + void persistGatewaySessionLifecycleEvent({ + sessionKey, + agentId: sessionAgentId, + event: evt, + }).catch(() => undefined); const sessionEventConnIds = sessionEventSubscribers.getAll(); if (sessionEventConnIds.size > 0) { broadcastToConnIds( "sessions.changed", { sessionKey, + ...(sessionAgentId ? { agentId: sessionAgentId } : {}), phase: lifecyclePhase, runId: evt.runId, ...(eventRunId !== evt.runId ? { clientRunId: eventRunId } : {}), ts: evt.ts, - ...buildSessionEventSnapshot(sessionKey, evt), + ...buildSessionEventSnapshot(sessionKey, evt, sessionAgentId), }, sessionEventConnIds, { dropIfSlow: true }, diff --git a/src/gateway/server-close.ts b/src/gateway/server-close.ts index 8e877bb6f248..0d82296c0e17 100644 --- a/src/gateway/server-close.ts +++ b/src/gateway/server-close.ts @@ -15,7 +15,7 @@ import { measureGatewayRestartTrace, recordGatewayRestartTrace, } from "./restart-trace.js"; -import type { ChatRunState } from "./server-chat-state.js"; +import type { ChatRunEntry, ChatRunState } from "./server-chat-state.js"; import type { GatewayPostReadySidecarHandle } from "./server-startup-post-attach.js"; const shutdownLog = createSubsystemLogger("gateway/shutdown"); @@ -169,7 +169,7 @@ function abortActiveRunsForRestart(params: { sessionId: string, clientRunId: string, sessionKey?: string, - ) => { sessionKey: string; clientRunId: string } | undefined; + ) => ChatRunEntry | undefined; agentRunSeq: Map; broadcast: (event: string, payload: unknown, opts?: { dropIfSlow?: boolean }) => void; nodeSendToSession: (sessionKey: string, event: string, payload: unknown) => void; @@ -208,7 +208,7 @@ async function drainRestartPendingRepliesForShutdown(params: { sessionId: string, clientRunId: string, sessionKey?: string, - ) => { sessionKey: string; clientRunId: string } | undefined; + ) => ChatRunEntry | undefined; agentRunSeq: Map; broadcast: (event: string, payload: unknown, opts?: { dropIfSlow?: boolean }) => void; nodeSendToSession: (sessionKey: string, event: string, payload: unknown) => void; @@ -387,7 +387,7 @@ export function createGatewayCloseHandler(params: { sessionId: string, clientRunId: string, sessionKey?: string, - ) => { sessionKey: string; clientRunId: string } | undefined; + ) => ChatRunEntry | undefined; agentRunSeq: Map; nodeSendToSession: (sessionKey: string, event: string, payload: unknown) => void; getPendingReplyCount?: () => number; diff --git a/src/gateway/server-methods/agent.test.ts b/src/gateway/server-methods/agent.test.ts index b67622a1760b..89f3f3790318 100644 --- a/src/gateway/server-methods/agent.test.ts +++ b/src/gateway/server-methods/agent.test.ts @@ -1,5 +1,6 @@ import fs from "node:fs/promises"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ErrorCodes } from "../../../packages/gateway-protocol/src/index.js"; import { registerExecApprovalFollowupRuntimeHandoff, resetExecApprovalFollowupRuntimeHandoffsForTests, @@ -99,7 +100,10 @@ vi.mock("../../config/config.js", async () => { vi.mock("../../agents/agent-scope.js", () => ({ listAgentIds: mocks.listAgentIds, - resolveDefaultAgentId: () => "main", + resolveDefaultAgentId: (cfg?: { + agents?: { list?: Array<{ id?: string; default?: boolean }> }; + }) => + cfg?.agents?.list?.find((agent) => agent.default)?.id ?? cfg?.agents?.list?.[0]?.id ?? "main", resolveSessionAgentId: ({ sessionKey, }: { @@ -111,8 +115,18 @@ vi.mock("../../agents/agent-scope.js", () => ({ }, resolveAgentConfig: (cfg: { agents?: { list?: Array<{ id?: string }> } }, agentId: string) => cfg.agents?.list?.find((agent) => agent.id === agentId), - resolveAgentWorkspaceDir: (cfg: { agents?: { defaults?: { workspace?: string } } }) => - cfg?.agents?.defaults?.workspace ?? "/tmp/workspace", + resolveAgentWorkspaceDir: ( + cfg: { + agents?: { + defaults?: { workspace?: string }; + list?: Array<{ id?: string; workspace?: string }>; + }; + }, + agentId?: string, + ) => + cfg?.agents?.list?.find((agent) => agent.id === agentId)?.workspace ?? + cfg?.agents?.defaults?.workspace ?? + "/tmp/workspace", resolveAgentEffectiveModelPrimary: () => undefined, })); @@ -3535,7 +3549,7 @@ describe("gateway agent handler", () => { } }); - it("does not forward a non-main agent id with canonical global session keys", async () => { + it("forwards the selected agent id with canonical global session keys", async () => { mocks.listAgentIds.mockReturnValue(["main", "ops"]); mocks.resolveExplicitAgentSessionKey.mockReturnValue("agent:ops:main"); mocks.loadSessionEntry.mockReturnValue({ @@ -3571,8 +3585,292 @@ describe("gateway agent handler", () => { agentId?: string; sessionKey?: string; }>(); - expect(call.agentId).toBeUndefined(); + expect(call.agentId).toBe("ops"); expect(call.sessionKey).toBe("global"); + expect(mocks.loadSessionEntry).toHaveBeenCalledWith("agent:ops:main", { + agentId: "ops", + clone: false, + }); + }); + + it("accepts an explicit global session key with a selected agent id", async () => { + mocks.listAgentIds.mockReturnValue(["main", "work"]); + mocks.loadSessionEntry.mockReturnValue({ + cfg: { session: { scope: "global" } }, + storePath: "/tmp/sessions.json", + entry: { + sessionId: "global-work-session-id", + updatedAt: Date.now(), + }, + canonicalKey: "global", + }); + mocks.updateSessionStore.mockImplementation(async (_path, updater) => { + const store: Record = { + global: { sessionId: "global-work-session-id", updatedAt: Date.now() }, + }; + return await updater(store); + }); + mocks.agentCommand.mockResolvedValue({ + payloads: [{ text: "ok" }], + meta: { durationMs: 100 }, + }); + const respond = vi.fn(); + + await invokeAgent( + { + message: "global session", + sessionKey: "global", + agentId: "work", + idempotencyKey: "explicit-global-session-agent-id", + }, + { reqId: "explicit-global-session-agent-id", respond }, + ); + + expect(respond).not.toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ code: ErrorCodes.INVALID_REQUEST }), + ); + const call = await waitForAgentCommandCall<{ + agentId?: string; + sessionKey?: string; + }>(); + expect(call.agentId).toBe("work"); + expect(call.sessionKey).toBe("global"); + expect(mocks.loadSessionEntry).toHaveBeenCalledWith("global", { + agentId: "work", + clone: false, + }); + }); + + it("routes bare global session keys to the configured default agent", async () => { + mocks.listAgentIds.mockReturnValue(["main", "ops"]); + mocks.loadConfigReturn = { + agents: { list: [{ id: "main" }, { id: "ops", default: true }] }, + session: { scope: "global" }, + }; + mocks.loadSessionEntry.mockReturnValue({ + cfg: mocks.loadConfigReturn, + storePath: "/tmp/sessions.json", + entry: { + sessionId: "global-ops-session-id", + updatedAt: Date.now(), + }, + canonicalKey: "global", + }); + mocks.updateSessionStore.mockImplementation(async (_path, updater) => { + const store: Record = { + global: { sessionId: "global-ops-session-id", updatedAt: Date.now() }, + }; + return await updater(store); + }); + mocks.agentCommand.mockResolvedValue({ + payloads: [{ text: "ok" }], + meta: { durationMs: 100 }, + }); + + await invokeAgent( + { + message: "bare global session", + sessionKey: "global", + idempotencyKey: "bare-global-default-agent-id", + }, + { reqId: "bare-global-default-agent-id" }, + ); + + const call = await waitForAgentCommandCall<{ + agentId?: string; + sessionKey?: string; + }>(); + expect(call.agentId).toBe("ops"); + expect(call.sessionKey).toBe("global"); + expect(mocks.loadSessionEntry).toHaveBeenCalledWith("global", { + clone: false, + }); + }); + + it("infers selected-global agent id from agent-prefixed session aliases", async () => { + mocks.listAgentIds.mockReturnValue(["main", "work"]); + mocks.loadConfigReturn = { + agents: { list: [{ id: "main", default: true }, { id: "work" }] }, + session: { scope: "global" }, + }; + mocks.loadSessionEntry.mockReturnValue({ + cfg: mocks.loadConfigReturn, + storePath: "/tmp/sessions.json", + entry: { + sessionId: "global-work-session-id", + updatedAt: Date.now(), + }, + canonicalKey: "global", + }); + mocks.updateSessionStore.mockImplementation(async (_path, updater) => { + const store: Record = { + global: { sessionId: "global-work-session-id", updatedAt: Date.now() }, + }; + return await updater(store); + }); + mocks.agentCommand.mockResolvedValue({ + payloads: [{ text: "ok" }], + meta: { durationMs: 100 }, + }); + + await invokeAgent( + { + message: "global alias session", + sessionKey: "agent:work:main", + idempotencyKey: "alias-global-session-agent-id", + }, + { reqId: "alias-global-session-agent-id" }, + ); + + const call = await waitForAgentCommandCall<{ + agentId?: string; + sessionKey?: string; + }>(); + expect(call.agentId).toBe("work"); + expect(call.sessionKey).toBe("global"); + expect(mocks.loadSessionEntry).toHaveBeenCalledWith("agent:work:main", { + agentId: "work", + clone: false, + }); + }); + + it("registers tool event recipients for active selected-global alias runs", async () => { + mocks.listAgentIds.mockReturnValue(["main", "work"]); + mocks.loadConfigReturn = { + agents: { list: [{ id: "main", default: true }, { id: "work" }] }, + session: { scope: "global" }, + }; + mocks.loadSessionEntry.mockReturnValue({ + cfg: mocks.loadConfigReturn, + storePath: "/tmp/sessions.json", + entry: { + sessionId: "global-work-session-id", + updatedAt: Date.now(), + }, + canonicalKey: "global", + }); + mocks.updateSessionStore.mockImplementation(async (_path, updater) => { + const store: Record = { + global: { sessionId: "global-work-session-id", updatedAt: Date.now() }, + }; + return await updater(store); + }); + mocks.agentCommand.mockResolvedValue({ + payloads: [{ text: "ok" }], + meta: { durationMs: 100 }, + }); + const context = makeContext(); + const registerToolEventRecipient = vi.fn(); + context.registerToolEventRecipient = registerToolEventRecipient; + context.chatAbortControllers.set("run-existing", { + controller: new AbortController(), + sessionKey: "global", + agentId: "work", + clientRunId: "run-existing", + } as never); + + await invokeAgent( + { + message: "global alias session", + sessionKey: "agent:work:main", + idempotencyKey: "alias-global-tool-events", + }, + { + reqId: "alias-global-tool-events", + context, + client: { + connId: "conn-1", + connect: { caps: ["tool-events"] }, + } as never, + }, + ); + + expect(registerToolEventRecipient).toHaveBeenCalledWith("alias-global-tool-events", "conn-1"); + expect(registerToolEventRecipient).toHaveBeenCalledWith("run-existing", "conn-1"); + }); + + it("honors selected-global agent id when the request uses the main alias", async () => { + mocks.listAgentIds.mockReturnValue(["main", "work"]); + mocks.loadConfigReturn = { + agents: { list: [{ id: "main", default: true }, { id: "work" }] }, + session: { scope: "global" }, + }; + mocks.loadSessionEntry.mockReturnValue({ + cfg: mocks.loadConfigReturn, + storePath: "/tmp/sessions.json", + entry: { + sessionId: "global-work-session-id", + updatedAt: Date.now(), + }, + canonicalKey: "global", + }); + mocks.updateSessionStore.mockImplementation(async (_path, updater) => { + const store: Record = { + global: { sessionId: "global-work-session-id", updatedAt: Date.now() }, + }; + return await updater(store); + }); + mocks.agentCommand.mockResolvedValue({ + payloads: [{ text: "ok" }], + meta: { durationMs: 100 }, + }); + + await invokeAgent( + { + message: "global main alias", + agentId: "work", + sessionKey: "main", + idempotencyKey: "selected-global-main-alias-agent-id", + }, + { reqId: "selected-global-main-alias-agent-id" }, + ); + + const call = await waitForAgentCommandCall<{ + agentId?: string; + sessionKey?: string; + }>(); + expect(call.agentId).toBe("work"); + expect(call.sessionKey).toBe("global"); + expect(mocks.loadSessionEntry).toHaveBeenCalledWith("main", { + agentId: "work", + clone: false, + }); + }); + + it("preserves selected-global agent id on cached accepted responses", async () => { + const context = makeContext(); + mocks.agentCommand.mockClear(); + context.dedupe.set("agent:cached-global-work", { + ts: Date.now(), + ok: true, + payload: { + runId: "cached-global-work", + sessionKey: "global", + agentId: "work", + status: "accepted", + }, + }); + const respond = vi.fn(); + + await invokeAgent( + { + message: "global session retry", + sessionKey: "global", + agentId: "work", + idempotencyKey: "cached-global-work", + }, + { context, respond, reqId: "cached-global-work" }, + ); + + expectRecordFields(mockCallArg(respond, 0, 1), { + runId: "cached-global-work", + sessionKey: "global", + agentId: "work", + status: "in_flight", + }); + expect(mocks.agentCommand).not.toHaveBeenCalled(); }); it("dispatches async gateway agent task creation through the detached task runtime seam", async () => { @@ -3653,10 +3951,7 @@ describe("gateway agent handler", () => { canonicalKey: "agent:main:voice", }); mocks.updateSessionStore.mockResolvedValue(undefined); - mocks.agentCommand.mockResolvedValue({ - payloads: [{ text: "ok" }], - meta: { durationMs: 100 }, - }); + mocks.agentCommand.mockReturnValue(new Promise(() => {})); const respond = vi.fn(); await invokeAgent( { @@ -3695,10 +3990,7 @@ describe("gateway agent handler", () => { canonicalKey: "agent:main:main", }); mocks.updateSessionStore.mockResolvedValue(undefined); - mocks.agentCommand.mockResolvedValue({ - payloads: [{ text: "ok" }], - meta: { durationMs: 100 }, - }); + mocks.agentCommand.mockReturnValue(new Promise(() => {})); const respond = vi.fn(); await invokeAgent( @@ -4331,6 +4623,130 @@ describe("gateway agent handler", () => { resetTimeConfig(); }); + it("resets the selected global agent session from agent commands", async () => { + setupNewYorkTimeConfig("2026-01-29T01:30:00.000Z"); + mocks.listAgentIds.mockReturnValue(["main", "work"]); + mocks.loadConfigReturn = { + agents: { list: [{ id: "main", default: true }, { id: "work" }] }, + session: { scope: "global" }, + }; + mocks.performGatewaySessionReset.mockClear(); + mocks.performGatewaySessionReset.mockImplementation( + async (opts: { key: string; agentId?: string; reason: string; commandSource: string }) => { + expect(opts).toMatchObject({ + key: "global", + agentId: "work", + reason: "reset", + commandSource: "gateway:agent", + }); + return { + ok: true, + key: "global", + entry: { sessionId: "global-work-reset-session" }, + }; + }, + ); + mocks.loadSessionEntry.mockReturnValue({ + cfg: mocks.loadConfigReturn, + storePath: "/tmp/sessions.json", + entry: { + sessionId: "global-work-reset-session", + updatedAt: Date.now(), + }, + canonicalKey: "global", + }); + mocks.updateSessionStore.mockResolvedValue(undefined); + mocks.agentCommand.mockResolvedValue({ + payloads: [{ text: "ok" }], + meta: { durationMs: 100 }, + }); + + await invokeAgent( + { + message: "/reset check status", + sessionKey: "global", + agentId: "work", + idempotencyKey: "test-idem-reset-selected-global", + }, + { + reqId: "4c", + client: { connect: { scopes: ["operator.admin"] } } as AgentHandlerArgs["client"], + }, + ); + + expect(mocks.performGatewaySessionReset).toHaveBeenCalledTimes(1); + const call = await waitForAgentCommandCall<{ agentId?: string; sessionKey?: string }>(); + expect(call.agentId).toBe("work"); + expect(call.sessionKey).toBe("global"); + + resetTimeConfig(); + }); + + it("loads selected global reset startup context from the selected agent workspace", async () => { + await withTempDir({ prefix: "openclaw-gateway-selected-global-startup-" }, async (rootDir) => { + const mainWorkspace = `${rootDir}/main`; + const workWorkspace = `${rootDir}/work`; + await fs.mkdir(`${mainWorkspace}/memory`, { recursive: true }); + await fs.mkdir(`${workWorkspace}/memory`, { recursive: true }); + await fs.writeFile(`${mainWorkspace}/memory/2026-01-29.md`, "main workspace note", "utf-8"); + await fs.writeFile(`${workWorkspace}/memory/2026-01-29.md`, "work workspace note", "utf-8"); + setupNewYorkTimeConfig("2026-01-29T01:30:00.000Z"); + mocks.listAgentIds.mockReturnValue(["main", "work"]); + mocks.loadConfigReturn = { + agents: { + list: [ + { id: "main", default: true, workspace: mainWorkspace }, + { id: "work", workspace: workWorkspace }, + ], + }, + session: { scope: "global" }, + }; + mocks.performGatewaySessionReset.mockImplementation( + async (opts: { key: string; agentId?: string; reason: string; commandSource: string }) => { + expect(opts).toMatchObject({ + key: "global", + agentId: "work", + reason: "new", + commandSource: "gateway:agent", + }); + return { + ok: true, + key: "global", + entry: { sessionId: "global-work-reset-session" }, + }; + }, + ); + mocks.loadSessionEntry.mockReturnValue({ + cfg: mocks.loadConfigReturn, + storePath: "/tmp/sessions.json", + entry: { + sessionId: "global-work-reset-session", + updatedAt: Date.now(), + }, + canonicalKey: "global", + }); + + await invokeAgent( + { + message: "/new", + sessionKey: "global", + agentId: "work", + idempotencyKey: "test-idem-new-selected-global-startup", + }, + { + reqId: "4c-startup", + client: { connect: { scopes: ["operator.admin"] } } as AgentHandlerArgs["client"], + }, + ); + + const call = await waitForAgentCommandCall<{ agentId?: string; message?: string }>(); + expect(call.agentId).toBe("work"); + expect(call.message).toContain("work workspace note"); + expect(call.message).not.toContain("main workspace note"); + resetTimeConfig(); + }); + }); + it("uses request model override when resolving bare /new bootstrap file access", async () => { await withTempDir( { prefix: "openclaw-gateway-reset-model-override-" }, @@ -4630,6 +5046,71 @@ describe("gateway agent handler chat.abort integration", () => { expect(abortEntry.expiresAtMs - abortEntry.startedAtMs).toBeGreaterThan(24 * 60 * 60_000); }); + it("keeps selected-global goals on agent session change events", async () => { + const goal = { + schemaVersion: 1, + id: "goal-work-global", + objective: "Finish work global task", + status: "active", + createdAt: 1, + updatedAt: 2, + tokenStart: 0, + tokensUsed: 5, + continuationTurns: 0, + }; + mocks.listAgentIds.mockReturnValue(["main", "work"]); + mocks.resolveExplicitAgentSessionKey.mockReturnValue("global"); + mocks.loadSessionEntry.mockReturnValue({ + cfg: { agents: { list: [{ id: "main" }, { id: "work" }] }, session: { scope: "global" } }, + storePath: "/tmp/sessions.json", + entry: { + sessionId: "global-session-id", + updatedAt: Date.now(), + }, + canonicalKey: "global", + }); + mocks.loadGatewaySessionRow.mockReturnValue({ + key: "global", + sessionId: "global-session-id", + kind: "global", + updatedAt: Date.now(), + goal, + }); + mocks.updateSessionStore.mockResolvedValue(undefined); + mocks.agentCommand.mockReturnValue(new Promise(() => {})); + + const context = makeContext(); + context.getSessionEventSubscriberConnIds = () => new Set(["conn-1"]); + const runId = "idem-agent-global-goal-event"; + await invokeAgent( + { + message: "hi", + agentId: "work", + idempotencyKey: runId, + }, + { context, reqId: runId }, + ); + + await waitForAssertion(() => { + expect(mocks.loadGatewaySessionRow).toHaveBeenCalledWith("global", { agentId: "work" }); + expect(context.addChatRun).toHaveBeenCalledWith( + runId, + expect.objectContaining({ sessionKey: "global", agentId: "work" }), + ); + expect(context.chatAbortControllers.get(runId)?.agentId).toBe("work"); + expect(context.broadcastToConnIds).toHaveBeenCalledWith( + "sessions.changed", + expect.objectContaining({ + sessionKey: "global", + agentId: "work", + goal: expect.objectContaining({ id: "goal-work-global" }), + }), + new Set(["conn-1"]), + { dropIfSlow: true }, + ); + }); + }); + it("yields after the accepted ack before dispatching heavy agent work", async () => { prime(); mocks.agentCommand.mockReturnValueOnce(new Promise(() => {})); @@ -4878,6 +5359,101 @@ describe("gateway agent handler chat.abort integration", () => { }); }); + it("keeps selected-global alias scope when aborting during pre-accept setup", async () => { + mocks.listAgentIds.mockReturnValue(["main", "work"]); + mocks.loadConfigReturn = { + agents: { list: [{ id: "main", default: true }, { id: "work" }] }, + session: { scope: "global" }, + }; + mocks.loadSessionEntry.mockReturnValue({ + cfg: mocks.loadConfigReturn, + storePath: "/tmp/sessions.json", + entry: { + sessionId: "global-work-session-id", + updatedAt: Date.now(), + }, + canonicalKey: "global", + }); + const requestedSessionKey = "agent:work:main"; + let releaseSessionWrite: (() => void) | undefined; + let sessionWriteCalls = 0; + mocks.updateSessionStore.mockImplementation(async (_path, updater) => { + sessionWriteCalls += 1; + if (sessionWriteCalls === 1) { + await new Promise((resolve) => { + releaseSessionWrite = resolve; + }); + } + const store = { + global: { + sessionId: "global-work-session-id", + updatedAt: Date.now(), + }, + }; + return await updater(store); + }); + mocks.agentCommand.mockReturnValueOnce(new Promise(() => {})); + + const context = makeContext(); + const respond = vi.fn(); + const runId = "idem-selected-global-alias-abort-before-registration"; + const pending = invokeAgent( + { + message: "hi", + agentId: "work", + sessionKey: requestedSessionKey, + idempotencyKey: runId, + }, + { context, respond, reqId: runId, flushDispatch: false }, + ); + await waitForAssertion(() => expect(sessionWriteCalls).toBe(1)); + expect(context.chatAbortControllers.has(runId)).toBe(false); + expectRecordFields(context.dedupe.get(`agent:${runId}`)?.payload, { + runId, + sessionKey: "global", + agentId: "work", + status: "accepted", + }); + + const abortRespond = vi.fn(); + await chatHandlers["chat.abort"]({ + params: { sessionKey: "global", agentId: "work", runId }, + respond: abortRespond as never, + context, + req: { type: "req", id: "abort-selected-global-alias-req", method: "chat.abort" }, + client: null, + isWebchatConnect: () => false, + }); + + expectRecordFields(mockCallArg(abortRespond, 0, 1), { + aborted: true, + runIds: [runId], + }); + expectRecordFields(context.dedupe.get(`agent:${runId}`)?.payload, { + runId, + sessionKey: "global", + agentId: "work", + status: "timeout", + summary: "aborted", + stopReason: "rpc", + }); + + releaseSessionWrite?.(); + await pending; + await flushScheduledDispatchStep(); + + expect(mocks.agentCommand).not.toHaveBeenCalled(); + expect(context.chatAbortControllers.has(runId)).toBe(false); + const finalResponse = respond.mock.calls.find( + (call: unknown[]) => (call[1] as { status?: unknown } | undefined)?.status === "timeout", + ); + expectRecordFields(requireValue(finalResponse, "terminal response missing")[1], { + runId, + status: "timeout", + stopReason: "rpc", + }); + }); + it("does not dispatch when a stop command lands during pre-accept setup", async () => { prime(); const requestedSessionKey = "agent:main:legacy-main"; @@ -5124,6 +5700,114 @@ describe("gateway agent handler chat.abort integration", () => { }); }); + it("keeps selected-global agent scope while aborting during attachment setup", async () => { + mocks.listAgentIds.mockReturnValue(["main", "work"]); + mocks.loadSessionEntry.mockReturnValue({ + cfg: {}, + storePath: "/tmp/sessions.json", + entry: { + sessionId: "work-global-session-id", + updatedAt: Date.now(), + modelProvider: "test", + model: "vision-model", + }, + canonicalKey: "global", + }); + mocks.updateSessionStore.mockResolvedValue(undefined); + mocks.agentCommand.mockReturnValueOnce(new Promise(() => {})); + + let releaseCatalog: (() => void) | undefined; + const context = { + ...makeContext(), + loadGatewayModelCatalog: vi.fn( + async () => + await new Promise((resolve) => { + releaseCatalog = () => + resolve([ + { + id: "vision-model", + name: "vision-model", + provider: "test", + input: ["image"], + }, + ]); + }), + ), + } as unknown as GatewayRequestContext; + const respond = vi.fn(); + const runId = "idem-selected-global-abort-during-attachment-setup"; + const pending = invokeAgent( + { + message: "inspect this", + agentId: "work", + sessionKey: "global", + idempotencyKey: runId, + attachments: [ + { + type: "file", + mimeType: "image/png", + fileName: "pixel.png", + content: Buffer.from("not really a png").toString("base64"), + }, + ], + }, + { context, respond, reqId: runId, flushDispatch: false }, + ); + + await waitForAssertion(() => + expectRecordFields(context.dedupe.get(`agent:${runId}`)?.payload, { + runId, + sessionKey: "global", + agentId: "work", + status: "accepted", + }), + ); + await waitForAssertion(() => expect(context.loadGatewayModelCatalog).toHaveBeenCalled()); + expect(mocks.loadSessionEntry).toHaveBeenCalledWith("global", { + agentId: "work", + clone: false, + }); + expect(context.chatAbortControllers.has(runId)).toBe(false); + + const abortRespond = vi.fn(); + await chatHandlers["chat.abort"]({ + params: { sessionKey: "global", agentId: "work", runId }, + respond: abortRespond as never, + context, + req: { type: "req", id: "abort-selected-global-req", method: "chat.abort" }, + client: null, + isWebchatConnect: () => false, + }); + + expectRecordFields(mockCallArg(abortRespond, 0, 1), { + aborted: true, + runIds: [runId], + }); + expectRecordFields(context.dedupe.get(`agent:${runId}`)?.payload, { + runId, + sessionKey: "global", + agentId: "work", + status: "timeout", + summary: "aborted", + stopReason: "rpc", + }); + + releaseCatalog?.(); + await pending; + await flushScheduledDispatchStep(); + + expect(mocks.agentCommand).not.toHaveBeenCalled(); + expect(context.chatAbortControllers.has(runId)).toBe(false); + const finalResponse = respond.mock.calls.find( + (call: unknown[]) => (call[1] as { status?: unknown } | undefined)?.status === "timeout", + ); + expectRecordFields(requireValue(finalResponse, "terminal response missing")[1], { + runId, + status: "timeout", + stopReason: "rpc", + }); + }); + it("does not dispatch when chat.abort lands before voice wake reroutes the session", async () => { let releaseRouting: (() => void) | undefined; mocks.loadVoiceWakeRoutingConfig.mockImplementation( diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index 73482a503740..47b1052a7265 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -87,6 +87,7 @@ import { isAcpSessionKey, isSubagentSessionKey, normalizeAgentId, + parseAgentSessionKey, } from "../../routing/session-key.js"; import { defaultRuntime } from "../../runtime.js"; import { @@ -146,6 +147,7 @@ import { migrateAndPruneGatewaySessionStoreKey, resolveGatewaySessionStoreTarget, resolveGatewayModelSupportsImages, + resolveSessionStoreKey, resolveSessionModelRef, } from "../session-utils.js"; import { formatForLog } from "../ws-log.js"; @@ -255,6 +257,7 @@ function emitAgentSendSessionLifecycleTransition( async function runSessionResetFromAgent(params: { key: string; + agentId?: string; reason: "new" | "reset"; }): Promise< | { ok: true; key: string; sessionId?: string } @@ -262,6 +265,7 @@ async function runSessionResetFromAgent(params: { > { const result = await performGatewaySessionReset({ key: params.key, + ...(params.agentId ? { agentId: params.agentId } : {}), reason: params.reason, commandSource: "gateway:agent", }); @@ -280,11 +284,14 @@ function resolveSessionRuntimeWorkspace(params: { sessionKey: string; sessionEntry?: SessionEntry; spawnedBy?: string; + agentId?: string; }): { runtimeWorkspaceDir: string; isCanonicalWorkspace: boolean; } { - const sessionAgentId = resolveAgentIdFromSessionKey(params.sessionKey); + const sessionAgentId = params.agentId + ? normalizeAgentId(params.agentId) + : resolveAgentIdFromSessionKey(params.sessionKey); const workspaceOverride = resolveIngressWorkspaceOverrideForSpawnedRun({ spawnedBy: params.spawnedBy, workspaceDir: params.sessionEntry?.spawnedWorkspaceDir, @@ -306,11 +313,14 @@ function shouldSkipStartupContextForSpawnedSandbox(params: { cfg: OpenClawConfig; sessionKey: string; spawnedBy?: string; + agentId?: string; }): boolean { if (!params.spawnedBy) { return false; } - const agentId = resolveAgentIdFromSessionKey(params.sessionKey); + const agentId = params.agentId + ? normalizeAgentId(params.agentId) + : resolveAgentIdFromSessionKey(params.sessionKey); const sandboxCfg = resolveSandboxConfigForAgent(params.cfg, agentId); if (sandboxCfg.mode === "off") { return false; @@ -388,13 +398,21 @@ function emitSessionsChanged( GatewayRequestHandlerOptions["context"], "broadcastToConnIds" | "getSessionEventSubscriberConnIds" >, - payload: { sessionKey?: string; reason: string }, + payload: { sessionKey?: string; agentId?: string; reason: string }, ) { const connIds = context.getSessionEventSubscriberConnIds(); if (connIds.size === 0) { return; } - const sessionRow = payload.sessionKey ? loadGatewaySessionRow(payload.sessionKey) : null; + const sessionRow = payload.sessionKey + ? loadGatewaySessionRow( + payload.sessionKey, + payload.sessionKey === "global" && payload.agentId + ? { agentId: payload.agentId } + : undefined, + ) + : null; + const omitUnscopedGlobalGoal = payload.sessionKey === "global" && !payload.agentId; context.broadcastToConnIds( "sessions.changed", { @@ -440,6 +458,7 @@ function emitSessionsChanged( lastThreadId: sessionRow.lastThreadId, totalTokens: sessionRow.totalTokens, totalTokensFresh: sessionRow.totalTokensFresh, + ...(omitUnscopedGlobalGoal ? {} : { goal: sessionRow.goal ?? null }), contextTokens: sessionRow.contextTokens, estimatedCostUsd: sessionRow.estimatedCostUsd, responseUsage: sessionRow.responseUsage, @@ -515,6 +534,7 @@ function readGatewayDedupeEntry(params: { function isAcceptedAgentDedupePayload(payload: unknown): payload is { acceptedAt?: unknown; + agentId?: unknown; dedupeKeys?: unknown; expiresAtMs?: unknown; ownerConnId?: unknown; @@ -531,6 +551,7 @@ function isAcceptedAgentDedupePayload(payload: unknown): payload is { } function isPreRegistrationAbortedAgentDedupePayload(payload: unknown): payload is { + agentId?: unknown; runId?: unknown; sessionKey?: unknown; status: "timeout"; @@ -592,6 +613,7 @@ function setGatewayDedupeEntries(params: { function setAbortedAgentDedupeEntries(params: { dedupe: GatewayRequestContext["dedupe"]; keys: readonly string[]; + agentId?: string; runId: string; stopReason: string; }) { @@ -603,6 +625,7 @@ function setAbortedAgentDedupeEntries(params: { ok: true, payload: { runId: params.runId, + ...(params.agentId ? { agentId: params.agentId } : {}), status: "timeout" as const, summary: "aborted", stopReason: params.stopReason, @@ -943,12 +966,19 @@ export const agentHandlers: GatewayRequestHandlers = { typeof cached.payload.sessionKey === "string" && cached.payload.sessionKey.trim() ? cached.payload.sessionKey.trim() : undefined; + const cachedAgentId = + cachedSessionKey === "global" && + typeof cached.payload.agentId === "string" && + cached.payload.agentId.trim() + ? cached.payload.agentId.trim() + : undefined; respond( true, { runId: cachedRunId, status: "in_flight" as const, ...(cachedSessionKey ? { sessionKey: cachedSessionKey } : {}), + ...(cachedAgentId ? { agentId: cachedAgentId } : {}), }, undefined, { @@ -968,10 +998,11 @@ export const agentHandlers: GatewayRequestHandlers = { const ownerConnId = typeof client?.connId === "string" ? client.connId : undefined; const ownerDeviceId = typeof client?.connect?.device?.id === "string" ? client.connect.device.id : undefined; - const reservePreAcceptedAgentDedupe = (sessionKey?: string) => { + const reservePreAcceptedAgentDedupe = (sessionKey?: string, dedupeAgentId?: string) => { if (agentDedupeReserved || !sessionKey) { return; } + const dedupeSessionResolvesGlobal = resolveSessionStoreKey({ cfg, sessionKey }) === "global"; const acceptedAt = Date.now(); const pendingTimeoutMs = resolveAgentTimeoutMs({ cfg, @@ -987,6 +1018,7 @@ export const agentHandlers: GatewayRequestHandlers = { runId, status: "accepted" as const, sessionKey, + ...(dedupeSessionResolvesGlobal && dedupeAgentId ? { agentId: dedupeAgentId } : {}), acceptedAt, dedupeKeys: agentDedupeKeys, expiresAtMs: resolveAgentRunExpiresAtMs({ @@ -1056,6 +1088,27 @@ export const agentHandlers: GatewayRequestHandlers = { ); return; } + if (!agentId && requestedSessionKeyRaw) { + const parsed = parseAgentSessionKey(requestedSessionKeyRaw); + const inferredAgentId = + parsed && resolveSessionStoreKey({ cfg, sessionKey: requestedSessionKeyRaw }) === "global" + ? normalizeAgentId(parsed.agentId) + : undefined; + if (inferredAgentId) { + if (!knownAgents.includes(inferredAgentId)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid agent params: unknown agent id "${parsed?.agentId}"`, + ), + ); + return; + } + agentId = inferredAgentId; + } + } const requestedSessionId = normalizeOptionalString(request.sessionId); let requestedSessionKey = requestedSessionKeyRaw ?? @@ -1066,7 +1119,16 @@ export const agentHandlers: GatewayRequestHandlers = { }) : undefined); if (agentId && requestedSessionKeyRaw) { - const sessionAgentId = resolveAgentIdFromSessionKey(requestedSessionKeyRaw); + const parsedRequestedSessionKey = parseAgentSessionKey(requestedSessionKeyRaw); + const requestedCanonicalKey = resolveSessionStoreKey({ + cfg, + sessionKey: requestedSessionKeyRaw, + }); + const sessionAgentId = parsedRequestedSessionKey?.agentId + ? normalizeAgentId(parsedRequestedSessionKey.agentId) + : requestedCanonicalKey === "global" + ? agentId + : resolveAgentIdFromSessionKey(requestedSessionKeyRaw); if (sessionAgentId !== agentId) { respond( false, @@ -1081,8 +1143,12 @@ export const agentHandlers: GatewayRequestHandlers = { } // Reserve the run before awaited attachment/session/delivery work so duplicate calls dedupe and // pre-registration chat.abort can be made durable by idempotency key. - const preAcceptedReservedSessionKey = requestedSessionKey; - reservePreAcceptedAgentDedupe(preAcceptedReservedSessionKey); + const preAcceptedReservedSessionKey = + requestedSessionKey && + resolveSessionStoreKey({ cfg, sessionKey: requestedSessionKey }) === "global" + ? "global" + : requestedSessionKey; + reservePreAcceptedAgentDedupe(preAcceptedReservedSessionKey, agentId); try { let message = (request.message ?? "").trim(); @@ -1096,11 +1162,19 @@ export const agentHandlers: GatewayRequestHandlers = { let baseModel: string | undefined; let requestedSessionEntry: SessionEntry | undefined; if (requestedSessionKeyRaw) { - const { cfg: sessCfg, entry: sessEntry } = loadSessionEntry(requestedSessionKeyRaw, { + const { + cfg: sessCfg, + entry: sessEntry, + canonicalKey: sessCanonicalKey, + } = loadSessionEntry(requestedSessionKeyRaw, { + ...(agentId ? { agentId } : {}), clone: false, }); requestedSessionEntry = sessEntry; - const sessionAgentId = resolveAgentIdFromSessionKey(requestedSessionKeyRaw); + const sessionAgentId = + sessCanonicalKey === "global" && agentId + ? agentId + : resolveAgentIdFromSessionKey(sessCanonicalKey); const modelRef = resolveSessionModelRef(sessCfg, sessEntry, sessionAgentId); baseProvider = modelRef.provider; baseModel = modelRef.model; @@ -1251,6 +1325,7 @@ export const agentHandlers: GatewayRequestHandlers = { let bestEffortDeliver = requestedBestEffortDeliver ?? false; let cfgForAgent: OpenClawConfig | undefined; let resolvedSessionKey = requestedSessionKey; + let resolvedSessionAgentId: string | undefined; let isNewSession = false; let skipTimestampInjection = false; let shouldPrependStartupContext = false; @@ -1270,6 +1345,7 @@ export const agentHandlers: GatewayRequestHandlers = { normalizeOptionalLowercaseString(resetCommandMatch[1]) === "new" ? "new" : "reset"; const resetResult = await runSessionResetFromAgent({ key: requestedSessionKey, + ...(requestedSessionKey === "global" && agentId ? { agentId } : {}), reason: resetReason, }); if (!resetResult.ok) { @@ -1282,12 +1358,21 @@ export const agentHandlers: GatewayRequestHandlers = { if (postResetMessage) { message = postResetMessage; } else { - const resetLoadedSession = loadSessionEntry(requestedSessionKey, { clone: false }); + const selectedGlobalResetAgentId = + requestedSessionKey === "global" && agentId ? agentId : undefined; + const resetLoadedSession = loadSessionEntry(requestedSessionKey, { + clone: false, + ...(selectedGlobalResetAgentId ? { agentId: selectedGlobalResetAgentId } : {}), + }); const resetCfg = resetLoadedSession?.cfg ?? cfg; const resetSessionEntry = resetLoadedSession?.entry; + const resetSessionAgentId = + selectedGlobalResetAgentId ?? + resolveAgentIdFromSessionKey(requestedSessionKey) ?? + resolveDefaultAgentId(resetCfg); const resetSpawnedBy = canonicalizeSpawnedByForAgent( resetCfg, - resolveAgentIdFromSessionKey(requestedSessionKey), + resetSessionAgentId, resetSessionEntry?.spawnedBy, ); const { runtimeWorkspaceDir, isCanonicalWorkspace } = resolveSessionRuntimeWorkspace({ @@ -1295,8 +1380,8 @@ export const agentHandlers: GatewayRequestHandlers = { sessionKey: requestedSessionKey, sessionEntry: resetSessionEntry, spawnedBy: resetSpawnedBy, + agentId: resetSessionAgentId, }); - const resetSessionAgentId = resolveAgentIdFromSessionKey(requestedSessionKey); const resetBaseModelRef = resolveSessionModelRef( resetCfg, resetSessionEntry, @@ -1342,13 +1427,22 @@ export const agentHandlers: GatewayRequestHandlers = { } if (requestedSessionKey) { - const { cfg, storePath, entry, canonicalKey } = loadSessionEntry(requestedSessionKey, { + const sessionLoadOptions = { + ...(agentId ? { agentId } : {}), clone: false, - }); + }; + const { cfg, storePath, entry, canonicalKey } = loadSessionEntry( + requestedSessionKey, + sessionLoadOptions, + ); cfgForAgent = cfg; const sessionMaintenanceConfig = resolveMaintenanceConfigFromInput( cfg.session?.maintenance, ); + const canonicalSessionAgentId = + canonicalKey === "global" + ? (agentId ?? resolveDefaultAgentId(cfg)) + : resolveAgentIdFromSessionKey(canonicalKey); const now = Date.now(); const resetPolicy = resolveSessionResetPolicy({ sessionCfg: cfg.session, @@ -1362,7 +1456,7 @@ export const agentHandlers: GatewayRequestHandlers = { ? resolveSessionLifecycleTimestamps({ entry, storePath, - agentId: resolveAgentIdFromSessionKey(canonicalKey), + agentId: canonicalSessionAgentId, }) : undefined; const freshness = entry @@ -1378,7 +1472,7 @@ export const agentHandlers: GatewayRequestHandlers = { try { const sessionPathOpts = resolveSessionFilePathOptions({ storePath, - agentId: resolveAgentIdFromSessionKey(canonicalKey), + agentId: canonicalSessionAgentId, }); failedSessionTranscriptMissing = !existsSync( resolveSessionFilePath(entry.sessionId, entry, sessionPathOpts), @@ -1407,7 +1501,7 @@ export const agentHandlers: GatewayRequestHandlers = { request.bootstrapContextRunKind !== "cron" && request.bootstrapContextRunKind !== "heartbeat" && !request.internalEvents?.length; - const sessionAgent = resolveAgentIdFromSessionKey(canonicalKey); + const sessionAgent = canonicalSessionAgentId; type AgentSessionPatchBuild = { patch: Partial; spawnedBy: string | undefined; @@ -1560,8 +1654,9 @@ export const agentHandlers: GatewayRequestHandlers = { resolvedSessionId = sessionEntry?.sessionId ?? sessionId; const canonicalSessionKey = canonicalKey; resolvedSessionKey = canonicalSessionKey; - const agentId = resolveAgentIdFromSessionKey(canonicalSessionKey); - const mainSessionKey = resolveAgentMainSessionKey({ cfg, agentId }); + const sessionAgentId = canonicalSessionAgentId; + resolvedSessionAgentId = sessionAgentId; + const mainSessionKey = resolveAgentMainSessionKey({ cfg, agentId: sessionAgentId }); // Legacy stores may lack sessionStartedAt entirely. Pre-compute a // JSONL-transcript-derived candidate outside the store lock; the // updater below only writes it when the freshly-loaded store still @@ -1572,7 +1667,7 @@ export const agentHandlers: GatewayRequestHandlers = { ? resolveSessionLifecycleTimestamps({ entry, storePath, - agentId, + agentId: sessionAgentId, }).sessionStartedAt : undefined; if (storePath && !suppressVisibleSessionEffects) { @@ -1592,6 +1687,7 @@ export const agentHandlers: GatewayRequestHandlers = { cfg, key: requestedStoreKey, store, + ...(sessionAgentId ? { agentId: sessionAgentId } : {}), }); const hadLegacyStoreKey = preMigrationTarget.storeKeys.some( (storeKey) => @@ -1678,7 +1774,7 @@ export const agentHandlers: GatewayRequestHandlers = { sessionId: resolvedSessionId, storePath, sessionFile: sessionEntry?.sessionFile, - agentId, + agentId: sessionAgentId, previousSessionId, previousSessionFile: previousSessionId ? entry?.sessionFile : undefined, previousEndReason: previousSessionId @@ -1711,8 +1807,11 @@ export const agentHandlers: GatewayRequestHandlers = { !suppressVisibleSessionEffects && (canonicalSessionKey === mainSessionKey || canonicalSessionKey === "global") ) { + const selectedGlobalAgentId = + canonicalSessionKey === "global" ? sessionAgentId : undefined; context.addChatRun(idem, { sessionKey: canonicalSessionKey, + ...(selectedGlobalAgentId ? { agentId: selectedGlobalAgentId } : {}), clientRunId: idem, }); if (requestedBestEffortDeliver === undefined) { @@ -1727,6 +1826,13 @@ export const agentHandlers: GatewayRequestHandlers = { ); } + const activeSessionAgentId = + resolvedSessionKey === "global" && resolvedSessionAgentId + ? resolvedSessionAgentId + : resolvedSessionKey + ? resolveAgentIdFromSessionKey(resolvedSessionKey) + : (agentId ?? resolveDefaultAgentId(cfgForAgent ?? cfg)); + const connId = typeof client?.connId === "string" ? client.connId : undefined; const wantsToolEvents = hasGatewayClientCap( client?.connect?.caps, @@ -1738,7 +1844,10 @@ export const agentHandlers: GatewayRequestHandlers = { // late-joining clients (e.g. page refresh mid-response) receive // in-progress tool events without leaking cross-session data. for (const [activeRunId, active] of context.chatAbortControllers) { - if (activeRunId !== runId && active.sessionKey === requestedSessionKey) { + const sameSession = active.sessionKey === resolvedSessionKey; + const sameSelectedGlobalAgent = + resolvedSessionKey === "global" ? active.agentId === activeSessionAgentId : true; + if (activeRunId !== runId && sameSession && sameSelectedGlobalAgent) { context.registerToolEventRecipient(activeRunId, connId); } } @@ -1753,9 +1862,7 @@ export const agentHandlers: GatewayRequestHandlers = { const turnSourceAccountId = normalizeOptionalString(request.accountId); const deliveryPlan = await resolveAgentDeliveryPlanWithSessionRoute({ cfg: cfgForAgent ?? cfg, - agentId: resolvedSessionKey - ? resolveAgentIdFromSessionKey(resolvedSessionKey) - : (agentId ?? resolveDefaultAgentId(cfgForAgent ?? cfg)), + agentId: activeSessionAgentId, currentSessionKey: resolvedSessionKey, sessionEntry, requestedChannel: request.replyChannel ?? request.channel, @@ -1906,13 +2013,7 @@ export const agentHandlers: GatewayRequestHandlers = { }); const activeModelProvider = providerOverride ?? - resolveSessionModelRef( - cfgForAgent ?? cfg, - sessionEntry, - resolvedSessionKey - ? resolveAgentIdFromSessionKey(resolvedSessionKey) - : (agentId ?? resolveDefaultAgentId(cfgForAgent ?? cfg)), - ).provider; + resolveSessionModelRef(cfgForAgent ?? cfg, sessionEntry, activeSessionAgentId).provider; const activeAuthProvider = resolveProviderIdForAuth(activeModelProvider, { config: cfgForAgent ?? cfg, }); @@ -1921,6 +2022,7 @@ export const agentHandlers: GatewayRequestHandlers = { runId, sessionId: resolvedSessionId ?? runId, sessionKey: resolvedSessionKey, + agentId: resolvedSessionKey === "global" ? activeSessionAgentId : undefined, timeoutMs, now, expiresAtMs: resolveAgentRunExpiresAtMs({ now, timeoutMs }), @@ -1943,6 +2045,7 @@ export const agentHandlers: GatewayRequestHandlers = { const accepted = { runId, sessionKey: resolvedSessionKey, + ...(resolvedSessionKey === "global" ? { agentId: activeSessionAgentId } : {}), status: "accepted" as const, acceptedAt: Date.now(), }; @@ -1978,6 +2081,7 @@ export const agentHandlers: GatewayRequestHandlers = { setAbortedAgentDedupeEntries({ dedupe: context.dedupe, keys: agentDedupeKeys, + agentId: resolvedSessionKey === "global" ? activeSessionAgentId : undefined, runId, stopReason, }); @@ -2007,12 +2111,14 @@ export const agentHandlers: GatewayRequestHandlers = { if (requestedSessionKey && resolvedSessionKey && isNewSession) { emitSessionsChanged(context, { sessionKey: resolvedSessionKey, + ...(resolvedSessionKey === "global" ? { agentId: activeSessionAgentId } : {}), reason: "create", }); } if (resolvedSessionKey) { emitSessionsChanged(context, { sessionKey: resolvedSessionKey, + ...(resolvedSessionKey === "global" ? { agentId: activeSessionAgentId } : {}), reason: "send", }); } @@ -2024,6 +2130,7 @@ export const agentHandlers: GatewayRequestHandlers = { cfg: startupCfg, sessionKey: resolvedSessionKey, spawnedBy: spawnedByValue, + agentId: activeSessionAgentId, }) ) { const { runtimeWorkspaceDir } = resolveSessionRuntimeWorkspace({ @@ -2031,6 +2138,7 @@ export const agentHandlers: GatewayRequestHandlers = { sessionKey: resolvedSessionKey, sessionEntry, spawnedBy: spawnedByValue, + agentId: activeSessionAgentId, }); const startupContextPrelude = await buildSessionStartupContextPrelude({ workspaceDir: runtimeWorkspaceDir, @@ -2047,10 +2155,13 @@ export const agentHandlers: GatewayRequestHandlers = { const resolvedThreadId = explicitThreadId ?? deliveryPlan.resolvedThreadId; const ingressAgentId = - agentId && - (!resolvedSessionKey || resolveAgentIdFromSessionKey(resolvedSessionKey) === agentId) - ? agentId - : undefined; + resolvedSessionKey === "global" + ? activeSessionAgentId + : agentId && + (!resolvedSessionKey || + resolveAgentIdFromSessionKey(resolvedSessionKey) === agentId) + ? agentId + : undefined; let execApprovalFollowupRuntimeHandoff = canUseInternalRuntimeHandoff && execApprovalFollowupApprovalId ? consumeExecApprovalFollowupRuntimeHandoff({ diff --git a/src/gateway/server-methods/chat-transcript-inject.ts b/src/gateway/server-methods/chat-transcript-inject.ts index 767c36139a1f..cdb35b5236d8 100644 --- a/src/gateway/server-methods/chat-transcript-inject.ts +++ b/src/gateway/server-methods/chat-transcript-inject.ts @@ -49,6 +49,8 @@ function resolveInjectedAssistantContent(params: { export async function appendInjectedAssistantMessageToTranscript(params: { transcriptPath: string; + sessionKey?: string; + agentId?: string; message: string; label?: string; /** When set, used as the assistant `content` array (e.g. text + embedded audio blocks). */ @@ -118,6 +120,8 @@ export async function appendInjectedAssistantMessageToTranscript(params: { }); emitSessionTranscriptUpdate({ sessionFile: params.transcriptPath, + ...(params.sessionKey ? { sessionKey: params.sessionKey } : {}), + ...(params.agentId ? { agentId: params.agentId } : {}), message: appendedMessage, messageId, }); diff --git a/src/gateway/server-methods/chat.abort-persistence.test.ts b/src/gateway/server-methods/chat.abort-persistence.test.ts index 4e94273f0659..b77852bcf5a9 100644 --- a/src/gateway/server-methods/chat.abort-persistence.test.ts +++ b/src/gateway/server-methods/chat.abort-persistence.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { CURRENT_SESSION_VERSION } from "openclaw/plugin-sdk/agent-sessions"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { onAgentEvent, resetAgentEventsForTest } from "../../infra/agent-events.js"; import { createActiveRun, createChatAbortContext, @@ -17,6 +18,9 @@ const sessionEntryState = vi.hoisted(() => ({ transcriptPath: "", sessionId: "", hasEntry: true, + canonicalKey: "main", + cfg: {} as Record, + loadCalls: [] as Array<{ sessionKey: string; opts?: { agentId?: string } }>, })); vi.mock("../session-utils.js", async () => { @@ -24,17 +28,20 @@ vi.mock("../session-utils.js", async () => { await vi.importActual("../session-utils.js"); return { ...original, - loadSessionEntry: () => ({ - cfg: {}, - storePath: path.join(path.dirname(sessionEntryState.transcriptPath), "sessions.json"), - entry: sessionEntryState.hasEntry - ? { - sessionId: sessionEntryState.sessionId, - sessionFile: sessionEntryState.transcriptPath, - } - : undefined, - canonicalKey: "main", - }), + loadSessionEntry: (sessionKey: string, opts?: { agentId?: string }) => { + sessionEntryState.loadCalls.push({ sessionKey, opts }); + return { + cfg: sessionEntryState.cfg, + storePath: path.join(path.dirname(sessionEntryState.transcriptPath), "sessions.json"), + entry: sessionEntryState.hasEntry + ? { + sessionId: sessionEntryState.sessionId, + sessionFile: sessionEntryState.transcriptPath, + } + : undefined, + canonicalKey: sessionEntryState.canonicalKey, + }; + }, }; }); @@ -149,6 +156,9 @@ function setMockSessionEntry(transcriptPath: string, sessionId: string, hasEntry sessionEntryState.transcriptPath = transcriptPath; sessionEntryState.sessionId = sessionId; sessionEntryState.hasEntry = hasEntry; + sessionEntryState.canonicalKey = "main"; + sessionEntryState.cfg = {}; + sessionEntryState.loadCalls = []; } async function createTranscriptFixture(prefix: string) { @@ -170,6 +180,7 @@ async function createMissingEntryFixture(prefix: string) { afterEach(() => { vi.restoreAllMocks(); + resetAgentEventsForTest(); }); describe("chat abort transcript persistence", () => { @@ -483,6 +494,554 @@ describe("chat abort transcript persistence", () => { expect(context.chatAbortControllers.has("run-stop-raw-alias")).toBe(false); }); + it("scopes global stop commands to the selected agent", async () => { + const { sessionId } = await createTranscriptFixture("openclaw-chat-stop-global-agent-"); + sessionEntryState.canonicalKey = "global"; + sessionEntryState.cfg = { + agents: { + list: [{ id: "main", default: true }, { id: "work" }], + }, + }; + const respond = vi.fn(); + const mainActive = createActiveRun("global", { + sessionId: "sess-main-global", + agentId: "main", + }); + const workActive = createActiveRun("global", { + sessionId, + agentId: "work", + }); + const context = createChatAbortContext({ + chatAbortControllers: new Map([ + ["run-main-global", mainActive], + ["run-work-global", workActive], + ]), + chatRunBuffers: new Map([["run-work-global", "partial work response"]]), + removeChatRun: vi.fn().mockReturnValue({ + sessionKey: "global", + agentId: "work", + clientRunId: "run-work-global", + }), + }); + + await chatHandlers["chat.send"]({ + params: { + sessionKey: "global", + agentId: "work", + message: "stop", + idempotencyKey: "idem-stop-work-global", + }, + respond, + context: context as never, + req: {} as never, + client: null, + isWebchatConnect: () => false, + }); + + const [ok, payload] = requireLastRespondCall(respond); + expect(ok).toBe(true); + expectAbortPayload(payload, { runIds: ["run-work-global"] }); + expect(mainActive.controller.signal.aborted).toBe(false); + expect(workActive.controller.signal.aborted).toBe(true); + expect(sessionEntryState.loadCalls).toContainEqual({ + sessionKey: "global", + opts: { agentId: "work" }, + }); + }); + + it("scopes bare global stop commands to the default agent", async () => { + const { sessionId } = await createTranscriptFixture("openclaw-chat-stop-global-default-"); + sessionEntryState.canonicalKey = "global"; + sessionEntryState.cfg = { + agents: { + list: [{ id: "main", default: true }, { id: "work" }], + }, + }; + const respond = vi.fn(); + const mainActive = createActiveRun("global", { + sessionId, + agentId: "main", + }); + const workActive = createActiveRun("global", { + sessionId: "sess-work-global", + agentId: "work", + }); + const context = createChatAbortContext({ + chatAbortControllers: new Map([ + ["run-main-global", mainActive], + ["run-work-global", workActive], + ]), + chatRunBuffers: new Map([["run-main-global", "partial main response"]]), + removeChatRun: vi.fn().mockReturnValue({ + sessionKey: "global", + agentId: "main", + clientRunId: "run-main-global", + }), + }); + + await chatHandlers["chat.send"]({ + params: { + sessionKey: "global", + message: "stop", + idempotencyKey: "idem-stop-default-global", + }, + respond, + context: context as never, + req: {} as never, + client: null, + isWebchatConnect: () => false, + }); + + const [ok, payload] = requireLastRespondCall(respond); + expect(ok).toBe(true); + expectAbortPayload(payload, { runIds: ["run-main-global"] }); + expect(mainActive.controller.signal.aborted).toBe(true); + expect(workActive.controller.signal.aborted).toBe(false); + }); + + it("scopes global chat.abort requests to the selected agent", async () => { + const respond = vi.fn(); + const mainActive = createActiveRun("global", { + sessionId: "sess-main-global", + agentId: "main", + }); + const workActive = createActiveRun("global", { + sessionId: "sess-work-global", + agentId: "work", + }); + const context = createChatAbortContext({ + chatAbortControllers: new Map([ + ["run-main-global", mainActive], + ["run-work-global", workActive], + ]), + }); + const agentEvents: Array<{ runId: string; sessionKey?: string; agentId?: string }> = []; + const unsubscribe = onAgentEvent((event) => { + agentEvents.push({ + runId: event.runId, + sessionKey: event.sessionKey, + agentId: event.agentId, + }); + }); + + try { + await chatHandlers["chat.abort"]({ + params: { + sessionKey: "global", + agentId: "work", + }, + respond, + context: context as never, + req: {} as never, + client: null, + isWebchatConnect: () => false, + }); + } finally { + unsubscribe(); + } + + const [ok, payload] = requireLastRespondCall(respond); + expect(ok).toBe(true); + expectAbortPayload(payload, { runIds: ["run-work-global"] }); + expect(mainActive.controller.signal.aborted).toBe(false); + expect(workActive.controller.signal.aborted).toBe(true); + expect(agentEvents).toContainEqual({ + runId: "run-work-global", + sessionKey: "global", + agentId: "work", + }); + }); + + it("scopes bare global chat.abort requests to the default agent", async () => { + const respond = vi.fn(); + const mainActive = createActiveRun("global", { + sessionId: "sess-main-global", + agentId: "main", + }); + const workActive = createActiveRun("global", { + sessionId: "sess-work-global", + agentId: "work", + }); + const context = createChatAbortContext({ + chatAbortControllers: new Map([ + ["run-main-global", mainActive], + ["run-work-global", workActive], + ]), + getRuntimeConfig: () => ({ + agents: { list: [{ id: "main", default: true }, { id: "work" }] }, + session: { scope: "global" }, + }), + }); + + await chatHandlers["chat.abort"]({ + params: { + sessionKey: "global", + }, + respond, + context: context as never, + req: {} as never, + client: null, + isWebchatConnect: () => false, + }); + + const [ok, payload] = requireLastRespondCall(respond); + expect(ok).toBe(true); + expectAbortPayload(payload, { runIds: ["run-main-global"] }); + expect(mainActive.controller.signal.aborted).toBe(true); + expect(workActive.controller.signal.aborted).toBe(false); + }); + + it("infers selected global chat.abort scope from agent-prefixed aliases", async () => { + const respond = vi.fn(); + const mainActive = createActiveRun("global", { + sessionId: "sess-main-global", + agentId: "main", + }); + const workActive = createActiveRun("global", { + sessionId: "sess-work-global", + agentId: "work", + }); + const context = createChatAbortContext({ + chatAbortControllers: new Map([ + ["run-main-global", mainActive], + ["run-work-global", workActive], + ]), + getRuntimeConfig: () => ({ + agents: { list: [{ id: "main", default: true }, { id: "work" }] }, + session: { scope: "global" }, + }), + }); + + await chatHandlers["chat.abort"]({ + params: { + sessionKey: "agent:work:main", + }, + respond, + context: context as never, + req: {} as never, + client: null, + isWebchatConnect: () => false, + }); + + const [ok, payload] = requireLastRespondCall(respond); + expect(ok).toBe(true); + expectAbortPayload(payload, { runIds: ["run-work-global"] }); + expect(mainActive.controller.signal.aborted).toBe(false); + expect(workActive.controller.signal.aborted).toBe(true); + }); + + it("rejects selected global chat.abort when agentId conflicts with the key agent", async () => { + const respond = vi.fn(); + const context = createChatAbortContext({ + getRuntimeConfig: () => ({ + agents: { list: [{ id: "main", default: true }, { id: "work" }] }, + session: { scope: "global" }, + }), + }); + + await chatHandlers["chat.abort"]({ + params: { + sessionKey: "agent:main:main", + agentId: "work", + }, + respond, + context: context as never, + req: {} as never, + client: null, + isWebchatConnect: () => false, + }); + + const [ok, , error] = requireLastRespondCall(respond); + expect(ok).toBe(false); + expect(error).toEqual( + expect.objectContaining({ + message: 'agentId "work" does not match session key "agent:main:main"', + }), + ); + }); + + it("accepts selected global chat.abort run ids with agent-prefixed aliases", async () => { + const respond = vi.fn(); + const workActive = createActiveRun("global", { + sessionId: "sess-work-global", + agentId: "work", + }); + const context = createChatAbortContext({ + chatAbortControllers: new Map([["run-work-global", workActive]]), + getRuntimeConfig: () => ({ + agents: { list: [{ id: "main", default: true }, { id: "work" }] }, + session: { scope: "global" }, + }), + }); + + await chatHandlers["chat.abort"]({ + params: { + sessionKey: "agent:work:main", + runId: "run-work-global", + }, + respond, + context: context as never, + req: {} as never, + client: null, + isWebchatConnect: () => false, + }); + + const [ok, payload] = requireLastRespondCall(respond); + expect(ok).toBe(true); + expectAbortPayload(payload, { runIds: ["run-work-global"] }); + expect(workActive.controller.signal.aborted).toBe(true); + }); + + it("aborts pending selected global agent runs stored under agent-prefixed aliases", async () => { + const respond = vi.fn(); + const context = createChatAbortContext({ + getRuntimeConfig: () => ({ + agents: { list: [{ id: "main", default: true }, { id: "work" }] }, + session: { scope: "global" }, + }), + }); + context.dedupe.set("agent:run-work-global", { + ts: Date.now(), + ok: true, + payload: { + runId: "run-work-global", + sessionKey: "agent:work:main", + agentId: "work", + status: "accepted", + ownerConnId: "conn-work", + }, + }); + + await invokeChatAbortHandler({ + handler: chatHandlers["chat.abort"], + context, + request: { + sessionKey: "agent:work:main", + runId: "run-work-global", + }, + client: { connId: "conn-work" }, + respond, + }); + + const [ok, payload] = requireLastRespondCall(respond); + expect(ok).toBe(true); + expectAbortPayload(payload, { runIds: ["run-work-global"] }); + expect(context.dedupe.get("agent:run-work-global")).toEqual( + expect.objectContaining({ + payload: expect.objectContaining({ + sessionKey: "agent:work:main", + status: "timeout", + stopReason: "rpc", + }), + }), + ); + }); + + it("does not abort pending agent-prefixed global aliases for another selected agent", async () => { + const respond = vi.fn(); + const context = createChatAbortContext({ + getRuntimeConfig: () => ({ + agents: { list: [{ id: "main", default: true }, { id: "work" }] }, + session: { scope: "global" }, + }), + }); + context.dedupe.set("agent:run-main-global", { + ts: Date.now(), + ok: true, + payload: { + runId: "run-main-global", + sessionKey: "agent:main:main", + agentId: "main", + status: "accepted", + ownerConnId: "conn-main", + }, + }); + + await invokeChatAbortHandler({ + handler: chatHandlers["chat.abort"], + context, + request: { + sessionKey: "agent:work:main", + runId: "run-main-global", + }, + client: { connId: "conn-main" }, + respond, + }); + + const [ok, payload] = requireLastRespondCall(respond); + expect(ok).toBe(true); + const actual = expectRecord(payload, "abort payload"); + expect(actual.aborted).toBe(false); + expect(actual.runIds).toEqual([]); + expect(context.dedupe.get("agent:run-main-global")).toEqual( + expect.objectContaining({ + payload: expect.objectContaining({ + sessionKey: "agent:main:main", + status: "accepted", + }), + }), + ); + }); + + it("treats unscoped global runs as default-agent abort targets", async () => { + const respond = vi.fn(); + const mainActive = createActiveRun("global", { + sessionId: "sess-main-global", + }); + const workActive = createActiveRun("global", { + sessionId: "sess-work-global", + agentId: "work", + }); + const context = createChatAbortContext({ + chatAbortControllers: new Map([ + ["run-main-global", mainActive], + ["run-work-global", workActive], + ]), + }); + + await invokeChatAbortHandler({ + handler: chatHandlers["chat.abort"], + context, + request: { + sessionKey: "global", + agentId: "main", + }, + respond, + }); + + const [ok, payload] = requireLastRespondCall(respond); + expect(ok).toBe(true); + expectAbortPayload(payload, { runIds: ["run-main-global"] }); + expect(mainActive.controller.signal.aborted).toBe(true); + expect(workActive.controller.signal.aborted).toBe(false); + }); + + it("accepts default-agent runId aborts for legacy unscoped global runs", async () => { + const respond = vi.fn(); + const active = createActiveRun("global", { + sessionId: "sess-main-global", + }); + const context = createChatAbortContext({ + chatAbortControllers: new Map([["run-main-global", active]]), + }); + + await invokeChatAbortHandler({ + handler: chatHandlers["chat.abort"], + context, + request: { + sessionKey: "global", + agentId: "main", + runId: "run-main-global", + }, + respond, + }); + + const [ok, payload] = requireLastRespondCall(respond); + expect(ok).toBe(true); + expectAbortPayload(payload, { runIds: ["run-main-global"] }); + expect(active.controller.signal.aborted).toBe(true); + }); + + it("uses the configured default agent for legacy unscoped global aborts", async () => { + const respond = vi.fn(); + const active = createActiveRun("global", { + sessionId: "sess-work-global", + }); + const context = createChatAbortContext({ + getRuntimeConfig: () => ({ agents: { list: [{ id: "work", default: true }] } }), + chatAbortControllers: new Map([["run-work-global", active]]), + }); + + await invokeChatAbortHandler({ + handler: chatHandlers["chat.abort"], + context, + request: { + sessionKey: "global", + agentId: "work", + }, + respond, + }); + + const [ok, payload] = requireLastRespondCall(respond); + expect(ok).toBe(true); + expectAbortPayload(payload, { runIds: ["run-work-global"] }); + expect(active.controller.signal.aborted).toBe(true); + }); + + it("does not abort pending default global agent runs for another selected agent", async () => { + const respond = vi.fn(); + const context = createChatAbortContext(); + context.dedupe.set("agent:run-main-global", { + ts: Date.now(), + ok: true, + payload: { + runId: "run-main-global", + sessionKey: "global", + status: "accepted", + ownerConnId: "conn-main", + }, + }); + + await invokeChatAbortHandler({ + handler: chatHandlers["chat.abort"], + context, + request: { + sessionKey: "global", + agentId: "work", + runId: "run-main-global", + }, + client: { connId: "conn-main" }, + respond, + }); + + const [ok, payload] = requireLastRespondCall(respond); + expect(ok).toBe(true); + const actual = expectRecord(payload, "abort payload"); + expect(actual.aborted).toBe(false); + expect(actual.runIds).toEqual([]); + expect(context.dedupe.get("agent:run-main-global")).toEqual( + expect.objectContaining({ + payload: expect.objectContaining({ status: "accepted" }), + }), + ); + }); + + it("aborts pending default global agent runs for the default selected agent", async () => { + const respond = vi.fn(); + const context = createChatAbortContext(); + context.dedupe.set("agent:run-main-global", { + ts: Date.now(), + ok: true, + payload: { + runId: "run-main-global", + sessionKey: "global", + status: "accepted", + ownerConnId: "conn-main", + }, + }); + + await invokeChatAbortHandler({ + handler: chatHandlers["chat.abort"], + context, + request: { + sessionKey: "global", + agentId: "main", + runId: "run-main-global", + }, + client: { connId: "conn-main" }, + respond, + }); + + const [ok, payload] = requireLastRespondCall(respond); + expect(ok).toBe(true); + expectAbortPayload(payload, { runIds: ["run-main-global"] }); + expect(context.dedupe.get("agent:run-main-global")).toEqual( + expect.objectContaining({ + payload: expect.objectContaining({ status: "timeout", stopReason: "rpc" }), + }), + ); + }); + it("does not match stop targets by client-supplied session id without a stored entry", async () => { const { sessionId } = await createMissingEntryFixture("openclaw-chat-stop-client-session-"); const respond = vi.fn(); diff --git a/src/gateway/server-methods/chat.abort.test-helpers.ts b/src/gateway/server-methods/chat.abort.test-helpers.ts index be1c64fc9094..aed4d92152b2 100644 --- a/src/gateway/server-methods/chat.abort.test-helpers.ts +++ b/src/gateway/server-methods/chat.abort.test-helpers.ts @@ -6,6 +6,7 @@ export function createActiveRun( sessionKey: string, params: { sessionId?: string; + agentId?: string; owner?: { connId?: string; deviceId?: string }; } = {}, ) { @@ -14,6 +15,7 @@ export function createActiveRun( controller: new AbortController(), sessionId: params.sessionId ?? `${sessionKey}-session`, sessionKey, + agentId: params.agentId, startedAtMs: now, expiresAtMs: now + 30_000, ownerConnId: params.owner?.connId, @@ -32,7 +34,9 @@ type ChatAbortTestContext = Record & { bufferedAgentEvents: Map; chatAbortedRuns: Map; clearChatRunState: (runId: string) => void; - removeChatRun: (...args: unknown[]) => { sessionKey: string; clientRunId: string } | undefined; + removeChatRun: ( + ...args: unknown[] + ) => { sessionKey: string; agentId?: string; clientRunId: string } | undefined; agentRunSeq: Map; broadcast: (...args: unknown[]) => void; nodeSendToSession: (...args: unknown[]) => void; @@ -59,6 +63,7 @@ export function createChatAbortContext( .mockImplementation((run: string) => ({ sessionKey: "main", clientRunId: run })), clearChatRunState: (_runId: string) => {}, agentRunSeq: new Map(), + getRuntimeConfig: () => ({}), broadcast: vi.fn(), nodeSendToSession: vi.fn(), logGateway: { warn: vi.fn() }, @@ -82,7 +87,7 @@ export function createChatAbortContext( export async function invokeChatAbortHandler(params: { handler: GatewayRequestHandler; context: ChatAbortTestContext; - request: { sessionKey: string; runId?: string }; + request: { sessionKey: string; agentId?: string; runId?: string }; client?: { connId?: string; connect?: { diff --git a/src/gateway/server-methods/chat.directive-tags.test.ts b/src/gateway/server-methods/chat.directive-tags.test.ts index 55294e46d462..83107c879799 100644 --- a/src/gateway/server-methods/chat.directive-tags.test.ts +++ b/src/gateway/server-methods/chat.directive-tags.test.ts @@ -54,6 +54,7 @@ const mockState = vi.hoisted(() => ({ }; }>, dispatchError: null as Error | null, + dispatchWait: null as Promise | null, dispatchErrorAfterAgentRunStart: null as Error | null, dispatchErrorAfterDelivery: null as Error | null, triggerAgentRunStart: false, @@ -62,6 +63,7 @@ const mockState = vi.hoisted(() => ({ onAfterAgentRunStart: null as (() => void) | null, agentRunId: "run-agent-1", sessionEntry: {} as Record, + loadSessionEntryCalls: [] as Array<{ rawKey: string; opts?: { agentId?: string } }>, lastDispatchCtx: undefined as MsgContext | undefined, lastDispatchImages: undefined as Array<{ mimeType: string; data: string }> | undefined, lastDispatchImageOrder: undefined as string[] | undefined, @@ -129,28 +131,31 @@ vi.mock("../session-utils.js", async () => { await vi.importActual("../session-utils.js"); return { ...original, - loadSessionEntry: (rawKey: string) => ({ - ...(typeof mockState.sessionEntry.canonicalKey === "string" - ? { canonicalKey: mockState.sessionEntry.canonicalKey } - : {}), - cfg: { - ...mockState.config, - session: { - ...(mockState.config.session as Record | undefined), - mainKey: mockState.mainSessionKey, + loadSessionEntry: (rawKey: string, opts?: { agentId?: string }) => { + mockState.loadSessionEntryCalls.push({ rawKey, opts }); + return { + ...(typeof mockState.sessionEntry.canonicalKey === "string" + ? { canonicalKey: mockState.sessionEntry.canonicalKey } + : {}), + cfg: { + ...mockState.config, + session: { + ...(mockState.config.session as Record | undefined), + mainKey: mockState.mainSessionKey, + }, }, - }, - storePath: path.join(path.dirname(mockState.transcriptPath), "sessions.json"), - entry: { - sessionId: mockState.sessionId, - sessionFile: mockState.transcriptPath, - ...mockState.sessionEntry, - }, - canonicalKey: - typeof mockState.sessionEntry.canonicalKey === "string" - ? mockState.sessionEntry.canonicalKey - : rawKey || "main", - }), + storePath: path.join(path.dirname(mockState.transcriptPath), "sessions.json"), + entry: { + sessionId: mockState.sessionId, + sessionFile: mockState.transcriptPath, + ...mockState.sessionEntry, + }, + canonicalKey: + typeof mockState.sessionEntry.canonicalKey === "string" + ? mockState.sessionEntry.canonicalKey + : rawKey || "main", + }; + }, }; }); @@ -221,6 +226,9 @@ vi.mock("../../auto-reply/dispatch.js", () => ({ if (mockState.dispatchError) { throw mockState.dispatchError; } + if (mockState.dispatchWait) { + await mockState.dispatchWait; + } if (mockState.triggerAgentRunStart) { params.replyOptions?.onAgentRunStart?.(mockState.agentRunId); mockState.onAfterAgentRunStart?.(); @@ -638,6 +646,7 @@ function createChatContext(): Pick< | "dedupe" | "loadGatewayModelCatalog" | "registerToolEventRecipient" + | "getRuntimeConfig" | "logGateway" > { return { @@ -671,6 +680,14 @@ function createChatContext(): Pick< input: ["text", "image"], }, ], + getRuntimeConfig: () => + ({ + ...mockState.config, + session: { + ...(mockState.config.session as Record | undefined), + mainKey: mockState.mainSessionKey, + }, + }) as never, registerToolEventRecipient: vi.fn(), logGateway: { warn: vi.fn(), @@ -759,6 +776,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () => mockState.finalPayload = null; mockState.dispatchedReplies = []; mockState.dispatchError = null; + mockState.dispatchWait = null; mockState.dispatchErrorAfterAgentRunStart = null; mockState.dispatchErrorAfterDelivery = null; mockState.mainSessionKey = "main"; @@ -768,6 +786,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () => mockState.onAfterAgentRunStart = null; mockState.agentRunId = "run-agent-1"; mockState.sessionEntry = {}; + mockState.loadSessionEntryCalls = []; mockState.lastDispatchCtx = undefined; mockState.lastDispatchImages = undefined; mockState.lastDispatchImageOrder = undefined; @@ -857,6 +876,210 @@ describe("chat directive tag stripping for non-streaming final payloads", () => expect(register).not.toHaveBeenCalledWith("run-other-session", "conn-1"); }); + it("registers default global tool-event recipients for unscoped global sends", async () => { + createTranscriptFixture("openclaw-chat-send-global-tool-events-"); + mockState.config = { + agents: { list: [{ id: "main", default: true }, { id: "work" }] }, + session: { scope: "global" }, + }; + mockState.finalText = "ok"; + mockState.triggerAgentRunStart = true; + mockState.agentRunId = "run-current-global"; + const respond = vi.fn(); + const context = createChatContext(); + context.chatAbortControllers.set("run-default-global", { + controller: new AbortController(), + sessionId: "sess-default-global", + sessionKey: "global", + startedAtMs: Date.now(), + expiresAtMs: Date.now() + 10_000, + }); + context.chatAbortControllers.set("run-work-global", { + controller: new AbortController(), + sessionId: "sess-work-global", + sessionKey: "global", + agentId: "work", + startedAtMs: Date.now(), + expiresAtMs: Date.now() + 10_000, + }); + + await runNonStreamingChatSend({ + context, + respond, + sessionKey: "global", + idempotencyKey: "idem-global-tool-events", + client: { + connId: "conn-global", + connect: { caps: [GATEWAY_CLIENT_CAPS.TOOL_EVENTS] }, + }, + expectBroadcast: false, + }); + + const register = context.registerToolEventRecipient as unknown as ReturnType; + expect(register).toHaveBeenCalledWith("run-current-global", "conn-global"); + expect(register).toHaveBeenCalledWith("run-default-global", "conn-global"); + expect(register).not.toHaveBeenCalledWith("run-work-global", "conn-global"); + }); + + it("registers selected global alias tool-event recipients against the canonical run key", async () => { + createTranscriptFixture("openclaw-chat-send-global-alias-tool-events-"); + mockState.config = { + agents: { list: [{ id: "main", default: true }, { id: "work" }] }, + session: { scope: "global" }, + }; + mockState.sessionEntry = { canonicalKey: "global" }; + mockState.finalText = "ok"; + mockState.triggerAgentRunStart = true; + mockState.agentRunId = "run-current-work-global"; + const respond = vi.fn(); + const context = createChatContext(); + context.chatAbortControllers.set("run-default-global", { + controller: new AbortController(), + sessionId: "sess-default-global", + sessionKey: "global", + startedAtMs: Date.now(), + expiresAtMs: Date.now() + 10_000, + }); + context.chatAbortControllers.set("run-work-global", { + controller: new AbortController(), + sessionId: "sess-work-global", + sessionKey: "global", + agentId: "work", + startedAtMs: Date.now(), + expiresAtMs: Date.now() + 10_000, + }); + + await runNonStreamingChatSend({ + context, + respond, + sessionKey: "agent:work:main", + idempotencyKey: "idem-global-alias-tool-events", + client: { + connId: "conn-work", + connect: { caps: [GATEWAY_CLIENT_CAPS.TOOL_EVENTS] }, + }, + expectBroadcast: false, + }); + + const register = context.registerToolEventRecipient as unknown as ReturnType; + expect(register).toHaveBeenCalledWith("run-current-work-global", "conn-work"); + expect(register).toHaveBeenCalledWith("run-work-global", "conn-work"); + expect(register).not.toHaveBeenCalledWith("run-default-global", "conn-work"); + }); + + it("scopes selected-agent global aliases before loading chat session state", async () => { + createTranscriptFixture("openclaw-chat-send-global-alias-load-"); + mockState.config = { + agents: { list: [{ id: "main", default: true }, { id: "work" }] }, + session: { scope: "global" }, + }; + mockState.sessionEntry = { canonicalKey: "global" }; + const respond = vi.fn(); + const context = createChatContext(); + + await runNonStreamingChatSend({ + context, + respond, + sessionKey: "agent:work:main", + idempotencyKey: "idem-global-alias-load", + expectBroadcast: false, + }); + + expect(mockState.loadSessionEntryCalls[0]).toEqual({ + rawKey: "agent:work:main", + opts: { agentId: "work" }, + }); + }); + + it("accepts selected-agent global main aliases before loading chat session state", async () => { + createTranscriptFixture("openclaw-chat-send-global-main-alias-load-"); + mockState.config = { + agents: { list: [{ id: "main", default: true }, { id: "work" }] }, + session: { scope: "global" }, + }; + mockState.sessionEntry = { canonicalKey: "global" }; + const respond = vi.fn(); + const context = createChatContext(); + + await runNonStreamingChatSend({ + context, + respond, + sessionKey: "main", + requestParams: { agentId: "work" }, + idempotencyKey: "idem-global-main-alias-load", + expectBroadcast: false, + }); + + const [ok] = lastRespondCall(respond) ?? []; + expect(ok).toBe(true); + expect(mockState.lastDispatchCtx).toMatchObject({ + SessionKey: "global", + AgentId: "work", + }); + expect(mockState.loadSessionEntryCalls[0]).toEqual({ + rawKey: "main", + opts: { agentId: "work" }, + }); + }); + + it("registers selected-agent global aliases under the canonical abort key", async () => { + createTranscriptFixture("openclaw-chat-send-global-alias-abort-key-"); + mockState.config = { + agents: { list: [{ id: "main", default: true }, { id: "work" }] }, + session: { scope: "global" }, + }; + mockState.sessionEntry = { canonicalKey: "global" }; + let releaseDispatch: (() => void) | undefined; + mockState.dispatchWait = new Promise((resolve) => { + releaseDispatch = resolve; + }); + const respond = vi.fn(); + const context = createChatContext(); + + const pending = runNonStreamingChatSend({ + context, + respond, + sessionKey: "agent:work:main", + idempotencyKey: "idem-global-alias-abort-key", + waitFor: "none", + }); + + await waitForAssertion(() => { + expect(context.chatAbortControllers.get("idem-global-alias-abort-key")).toMatchObject({ + sessionKey: "global", + agentId: "work", + }); + }); + releaseDispatch?.(); + await pending; + }); + + it("scopes chat history global aliases before loading session state", async () => { + createTranscriptFixture("openclaw-chat-history-global-alias-load-"); + mockState.config = { + agents: { list: [{ id: "main", default: true }, { id: "work" }] }, + session: { scope: "global" }, + }; + mockState.sessionEntry = { canonicalKey: "global" }; + const respond = vi.fn(); + const context = createChatContext(); + mockState.loadSessionEntryCalls = []; + + await chatHandlers["chat.history"]({ + params: { sessionKey: "agent:work:main" }, + respond: respond as never, + req: {} as never, + client: null, + isWebchatConnect: () => false, + context: context as GatewayRequestContext, + }); + + expect(mockState.loadSessionEntryCalls).toContainEqual({ + rawKey: "agent:work:main", + opts: { agentId: "work" }, + }); + }); + it("does not register tool-event recipients without tool-events capability", async () => { createTranscriptFixture("openclaw-chat-send-tool-events-off-"); mockState.finalText = "ok"; @@ -2148,6 +2371,46 @@ describe("chat directive tag stripping for non-streaming final payloads", () => expect(nodeSend?.[2].sessionKey).toBe("agent:main:canon"); }); + it("chat.inject scopes selected-agent global sessions before appending", async () => { + createTranscriptFixture("openclaw-chat-inject-selected-global-"); + mockState.config = { + agents: { list: [{ id: "main", default: true }, { id: "work" }] }, + session: { scope: "global" }, + }; + mockState.sessionEntry = { canonicalKey: "global" }; + const respond = vi.fn(); + const context = createChatContext(); + + await chatHandlers["chat.inject"]({ + params: { + sessionKey: "main", + agentId: "work", + message: "hello selected global", + }, + respond, + req: {} as never, + client: null as never, + isWebchatConnect: () => false, + context: context as GatewayRequestContext, + }); + + const response = lastRespondCall(respond); + expect(response?.[0]).toBe(true); + expect(mockState.loadSessionEntryCalls[0]).toEqual({ + rawKey: "main", + opts: { agentId: "work" }, + }); + const broadcastPayload = lastBroadcastPayload(context); + expect(broadcastPayload).toMatchObject({ + sessionKey: "global", + agentId: "work", + state: "final", + }); + const nodeSend = lastNodeSendCall(context); + expect(nodeSend?.[0]).toBe("agent:work:global"); + expect(nodeSend?.[2]).toMatchObject({ sessionKey: "global", agentId: "work" }); + }); + it("chat.send non-streaming final strips external untrusted wrapper metadata from final payload text", async () => { createTranscriptFixture("openclaw-chat-send-untrusted-meta-"); mockState.finalText = `hello\n\n${UNTRUSTED_CONTEXT_SUFFIX}`; diff --git a/src/gateway/server-methods/chat.error-broadcast.test.ts b/src/gateway/server-methods/chat.error-broadcast.test.ts index dba4ba3b4d71..6e0a96d55b87 100644 --- a/src/gateway/server-methods/chat.error-broadcast.test.ts +++ b/src/gateway/server-methods/chat.error-broadcast.test.ts @@ -15,6 +15,7 @@ function createMockContext() { chatAbortControllers, agentRunSeq, dedupe, + getRuntimeConfig: () => ({ agents: { list: [{ id: "main", default: true }] } }), logGateway: { warn: vi.fn(), debug: vi.fn(), error: vi.fn() }, addChatRun: vi.fn(), removeChatRun: vi.fn(), @@ -62,4 +63,53 @@ describe("chat.send error broadcast", () => { }), ); }); + + it("scopes selected-agent global errors to the linked agent", async () => { + const ctx = createMockContext(); + const respond = vi.fn(); + + ctx.addChatRun.mockImplementation(() => { + throw Object.assign(new Error("LLM timeout"), { code: "TIMEOUT" }); + }); + + await chatHandlers["chat.send"]({ + params: { + sessionKey: "global", + agentId: "main", + message: "hello", + idempotencyKey: "test-run-global", + }, + respond: respond as never, + context: ctx as unknown as GatewayRequestContext, + req: {} as never, + client: null as never, + isWebchatConnect: () => false, + }); + + expect(ctx.broadcast).toHaveBeenCalledWith( + "chat", + expect.objectContaining({ + runId: "test-run-global", + sessionKey: "global", + agentId: "main", + state: "error", + }), + ); + expect(ctx.nodeSendToSession).toHaveBeenCalledWith( + "agent:main:global", + "chat", + expect.objectContaining({ + agentId: "main", + state: "error", + }), + ); + expect(ctx.nodeSendToSession).toHaveBeenCalledWith( + "global", + "chat", + expect.objectContaining({ + agentId: "main", + state: "error", + }), + ); + }); }); diff --git a/src/gateway/server-methods/chat.inject.parentid.test.ts b/src/gateway/server-methods/chat.inject.parentid.test.ts index 298101e669fb..20a7b4a0f9b5 100644 --- a/src/gateway/server-methods/chat.inject.parentid.test.ts +++ b/src/gateway/server-methods/chat.inject.parentid.test.ts @@ -101,12 +101,14 @@ describe("gateway chat.inject transcript writes", () => { sessionId: "sess-redact", }); const fakeApiKey = "sk-proj-FAKEKEYFORTESTINGONLY1234567890"; - const updates: Array<{ message?: unknown }> = []; + const updates: Array<{ message?: unknown; sessionKey?: string; agentId?: string }> = []; const unsubscribe = onSessionTranscriptUpdate((update) => updates.push(update)); try { const appended = await appendInjectedAssistantMessageToTranscript({ transcriptPath, + sessionKey: "global", + agentId: "work", message: `Here is your key: ${fakeApiKey}`, config: { logging: { redactSensitive: "tools" } }, }); @@ -114,6 +116,7 @@ describe("gateway chat.inject transcript writes", () => { expect(appended.ok).toBe(true); expect(JSON.stringify(appended.message)).not.toContain(fakeApiKey); expect(updates).toHaveLength(1); + expect(updates[0]).toMatchObject({ sessionKey: "global", agentId: "work" }); const lines = readTranscriptLines(transcriptPath); const last = JSON.parse(lines.at(-1) as string) as { message?: unknown }; diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index 48c0e0317bf5..fe58c62bb5b5 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -23,7 +23,12 @@ import { validateChatSendParams, } from "../../../packages/gateway-protocol/src/index.js"; import { CHAT_SEND_SESSION_KEY_MAX_LENGTH } from "../../../packages/gateway-protocol/src/schema.js"; -import { resolveAgentWorkspaceDir, resolveSessionAgentId } from "../../agents/agent-scope.js"; +import { + listAgentIds, + resolveDefaultAgentId, + resolveAgentWorkspaceDir, + resolveSessionAgentId, +} from "../../agents/agent-scope.js"; import { rewriteTranscriptEntriesInSessionFile } from "../../agents/embedded-agent-runner/transcript-rewrite.js"; import { runAgentHarnessBeforeMessageWriteHook } from "../../agents/harness/hook-helpers.js"; import { resolveProviderIdForAuth } from "../../agents/provider-auth-aliases.js"; @@ -64,6 +69,7 @@ import { import { createChannelMessageReplyPipeline } from "../../plugin-sdk/channel-outbound.js"; import type { ChannelRouteRef } from "../../plugin-sdk/channel-route.js"; import { isPluginOwnedSessionBindingRecord } from "../../plugins/conversation-binding.js"; +import { normalizeAgentId, scopeLegacySessionKeyToAgent } from "../../routing/session-key.js"; import { INTER_SESSION_PROMPT_PREFIX_BASE, normalizeInputProvenance, @@ -131,6 +137,7 @@ import { resolveDeletedAgentIdFromSessionKey, readRecentSessionMessagesAsync, resolveSessionModelRef, + resolveSessionStoreKey, } from "../session-utils.js"; import { formatForLog } from "../ws-log.js"; import { injectTimestamp, timestampOptsFromConfig } from "./agent-timestamp.js"; @@ -163,6 +170,7 @@ type AbortOrigin = "rpc" | "stop-command"; type AbortedPartialSnapshot = { runId: string; sessionId: string; + agentId?: string; text: string; abortOrigin: AbortOrigin; }; @@ -174,6 +182,7 @@ type ChatAbortRequester = { }; type PreRegisteredAgentDedupePayload = { + agentId?: unknown; dedupeKeys?: unknown; ownerConnId?: unknown; ownerDeviceId?: unknown; @@ -355,6 +364,80 @@ function buildActiveChatSendDedupeKey(params: { return `${ACTIVE_CHAT_SEND_DEDUPE_PREFIX}:${digest}`; } +function validateChatSelectedAgent(params: { + cfg: OpenClawConfig; + requestedSessionKey: string; + agentId?: string; +}): { ok: true; agentId?: string } | { ok: false; error: string } { + const agentId = params.agentId ? normalizeAgentId(params.agentId) : undefined; + if (!agentId) { + return { ok: true }; + } + if (!listAgentIds(params.cfg).includes(agentId)) { + return { ok: false, error: `Unknown agent id "${params.agentId}"` }; + } + const requestedSessionKey = params.requestedSessionKey.trim(); + const parsed = parseAgentSessionKey(requestedSessionKey); + if (parsed && normalizeAgentId(parsed.agentId) !== agentId) { + return { + ok: false, + error: `agentId "${params.agentId}" does not match session key "${params.requestedSessionKey}"`, + }; + } + if (requestedSessionKey.toLowerCase() === "global") { + return { ok: true, agentId }; + } + if (resolveSessionStoreKey({ cfg: params.cfg, sessionKey: requestedSessionKey }) === "global") { + return { ok: true, agentId }; + } + if (!parsed || normalizeAgentId(parsed.agentId) !== agentId) { + return { + ok: false, + error: `agentId "${params.agentId}" does not match session key "${params.requestedSessionKey}"`, + }; + } + return { ok: true, agentId }; +} + +function resolveRequestedChatAgentId(params: { + cfg?: OpenClawConfig; + requestedSessionKey: string; + agentId?: string; +}): string | undefined { + const explicitAgentId = normalizeOptionalText(params.agentId); + if (explicitAgentId) { + return normalizeAgentId(explicitAgentId); + } + if (!params.cfg) { + return undefined; + } + const parsed = parseAgentSessionKey(params.requestedSessionKey.trim()); + if ( + !parsed?.agentId || + resolveSessionStoreKey({ cfg: params.cfg, sessionKey: params.requestedSessionKey }) !== "global" + ) { + return undefined; + } + return normalizeAgentId(parsed.agentId); +} + +function resolveChatSendActiveScopeKey(params: { + sessionKey: string; + agentId?: string; + mainKey?: string; +}): string { + if (params.sessionKey !== "global" || !params.agentId) { + return params.sessionKey; + } + return ( + scopeLegacySessionKeyToAgent({ + agentId: params.agentId, + sessionKey: params.sessionKey, + mainKey: params.mainKey, + }) ?? params.sessionKey + ); +} + type ChatSendExplicitOrigin = { originatingChannel?: string; originatingTo?: string; @@ -390,6 +473,7 @@ type SideResultPayload = { kind: "btw"; runId: string; sessionKey: string; + agentId?: string; question: string; text: string; isError?: boolean; @@ -469,6 +553,7 @@ function extractAssistantDisplayTextFromContent( async function buildAssistantDisplayContentFromReplyPayloads(params: { sessionKey: string; + agentId?: string; payloads: ReplyPayload[]; managedImageLocalRoots?: Parameters[0]["localRoots"]; includeSensitiveMedia?: boolean; @@ -516,6 +601,7 @@ async function buildAssistantDisplayContentFromReplyPayloads(params: { ); const imageBlocks = await createManagedOutgoingImageBlocks({ sessionKey: params.sessionKey, + ...(params.sessionKey === "global" && params.agentId ? { agentId: params.agentId } : {}), mediaUrls, localRoots: params.managedImageLocalRoots, continueOnPrepareError: true, @@ -631,12 +717,20 @@ function hasManagedOutgoingAssistantContent( function scheduleChatHistoryManagedImageCleanup(params: { sessionKey: string; + agentId?: string; context: Pick; }) { - if (chatHistoryManagedImageCleanupState.has(params.sessionKey)) { + const cleanupKey = + params.sessionKey === "global" && params.agentId + ? `agent:${params.agentId}:global` + : params.sessionKey; + if (chatHistoryManagedImageCleanupState.has(cleanupKey)) { return; } - const pending = cleanupManagedOutgoingImageRecords({ sessionKey: params.sessionKey }) + const pending = cleanupManagedOutgoingImageRecords({ + sessionKey: params.sessionKey, + ...(params.sessionKey === "global" && params.agentId ? { agentId: params.agentId } : {}), + }) .then(() => undefined) .catch((error) => { params.context.logGateway.debug( @@ -644,11 +738,11 @@ function scheduleChatHistoryManagedImageCleanup(params: { ); }) .finally(() => { - if (chatHistoryManagedImageCleanupState.get(params.sessionKey) === pending) { - chatHistoryManagedImageCleanupState.delete(params.sessionKey); + if (chatHistoryManagedImageCleanupState.get(cleanupKey) === pending) { + chatHistoryManagedImageCleanupState.delete(cleanupKey); } }); - chatHistoryManagedImageCleanupState.set(params.sessionKey, pending); + chatHistoryManagedImageCleanupState.set(cleanupKey, pending); } function resolveChatSendOriginatingRoute(params: { @@ -1483,6 +1577,7 @@ async function findSourceReplyTranscriptMirrorByMetadata(params: { } async function appendAssistantTranscriptMessage(params: { + sessionKey: string; message: string; label?: string; content?: Array>; @@ -1535,6 +1630,8 @@ async function appendAssistantTranscriptMessage(params: { return await appendInjectedAssistantMessageToTranscript({ transcriptPath, + sessionKey: params.sessionKey, + ...(params.agentId ? { agentId: params.agentId } : {}), message: params.message, label: params.label, content: params.content, @@ -1563,6 +1660,7 @@ function collectSessionAbortPartials(params: { out.push({ runId, sessionId: active.sessionId, + agentId: active.agentId, text, abortOrigin: params.abortOrigin, }); @@ -1578,14 +1676,20 @@ async function persistAbortedPartials(params: { if (params.snapshots.length === 0) { return; } - const { cfg, storePath, entry } = loadSessionEntry(params.sessionKey); for (const snapshot of params.snapshots) { + const sessionLoadOptions = + params.sessionKey === "global" && snapshot.agentId + ? { agentId: snapshot.agentId } + : undefined; + const { cfg, storePath, entry } = loadSessionEntry(params.sessionKey, sessionLoadOptions); const sessionId = entry?.sessionId ?? snapshot.sessionId ?? snapshot.runId; const appended = await appendAssistantTranscriptMessage({ + sessionKey: params.sessionKey, message: snapshot.text, sessionId, storePath, sessionFile: entry?.sessionFile, + ...(snapshot.agentId ? { agentId: snapshot.agentId } : {}), createIfMissing: true, idempotencyKey: `${snapshot.runId}:assistant`, cfg, @@ -1611,6 +1715,7 @@ function createChatAbortOps(context: GatewayRequestContext): ChatAbortOps { clearChatRunState: context.clearChatRunState, removeChatRun: context.removeChatRun, agentRunSeq: context.agentRunSeq, + getRuntimeConfig: context.getRuntimeConfig, broadcast: context.broadcast, nodeSendToSession: context.nodeSendToSession, }; @@ -1709,6 +1814,8 @@ function readPreRegisteredAgentDedupePayloadForSession(params: { entry: GatewayRequestContext["dedupe"] extends Map ? T | undefined : never; runId: string; sessionKey: string; + agentId?: string; + defaultAgentId: string; }): PreRegisteredAgentDedupePayload | undefined { if (!params.entry?.ok) { return undefined; @@ -1721,7 +1828,26 @@ function readPreRegisteredAgentDedupePayloadForSession(params: { if (payloadRunId && payloadRunId !== params.runId) { return undefined; } - return normalizeUnknownText(payload.sessionKey) === params.sessionKey ? payload : undefined; + if (normalizeUnknownText(payload.sessionKey) !== params.sessionKey) { + return undefined; + } + const agentId = normalizeOptionalText(params.agentId)?.toLowerCase(); + if (agentId) { + const parsed = parseAgentSessionKey(params.sessionKey); + const sessionAgentId = + params.sessionKey === "global" + ? resolveStoredGlobalRunAgentId( + normalizeUnknownText(payload.agentId), + params.defaultAgentId, + ) + : parsed?.agentId + ? normalizeAgentId(parsed.agentId) + : undefined; + if (sessionAgentId && sessionAgentId !== agentId) { + return undefined; + } + } + return payload; } function readPreRegisteredAgentRun(params: { @@ -1777,6 +1903,13 @@ function resolvePreRegisteredAgentDedupeKeys( return uniqueStrings(keys); } +function resolveStoredGlobalRunAgentId( + agentId: string | undefined, + defaultAgentId: string, +): string { + return normalizeOptionalText(agentId)?.toLowerCase() ?? defaultAgentId.toLowerCase(); +} + function writePreRegisteredAgentAbort(params: { context: GatewayRequestContext; runId: string; @@ -1786,6 +1919,7 @@ function writePreRegisteredAgentAbort(params: { endedAt?: number; }) { const endedAt = params.endedAt ?? Date.now(); + const payloadAgentId = normalizeUnknownText(params.payload.agentId); for (const key of resolvePreRegisteredAgentDedupeKeys(params.payload, params.runId)) { setGatewayDedupeEntry({ dedupe: params.context.dedupe, @@ -1796,6 +1930,7 @@ function writePreRegisteredAgentAbort(params: { payload: { runId: params.runId, sessionKey: params.sessionKey, + ...(payloadAgentId ? { agentId: payloadAgentId } : {}), status: "timeout" as const, summary: "aborted", stopReason: params.stopReason, @@ -1809,6 +1944,8 @@ function writePreRegisteredAgentAbort(params: { function resolveAuthorizedPreRegisteredAgentRunsForSessionKeys(params: { context: GatewayRequestContext; sessionKeys: Iterable; + agentId?: string; + defaultAgentId: string; requester: ChatAbortRequester; }) { const sessionKeys = new Set( @@ -1826,6 +1963,17 @@ function resolveAuthorizedPreRegisteredAgentRunsForSessionKeys(params: { if (params.context.chatAbortControllers.has(run.runId)) { continue; } + const agentId = normalizeOptionalText(params.agentId)?.toLowerCase(); + if ( + agentId && + run.sessionKey === "global" && + resolveStoredGlobalRunAgentId( + normalizeUnknownText(run.payload.agentId), + params.defaultAgentId, + ) !== agentId + ) { + continue; + } matchedSessionRuns += 1; if (canRequesterAbortPreRegisteredAgentRun(run.payload, params.requester)) { authorizedByRunId.set(run.runId, run); @@ -1841,6 +1989,8 @@ function resolveAuthorizedRunsForSessionKeys(params: { chatAbortControllers: Map; sessionKeys: Iterable; sessionIds?: Iterable; + agentId?: string; + defaultAgentId: string; requester: ChatAbortRequester; }) { const sessionKeys = new Set( @@ -1853,12 +2003,20 @@ function resolveAuthorizedRunsForSessionKeys(params: { (sessionId): sessionId is string => Boolean(sessionId), ), ); + const agentId = normalizeOptionalText(params.agentId)?.toLowerCase(); const authorizedRuns: Array<{ runId: string; sessionKey: string }> = []; let matchedSessionRuns = 0; for (const [runId, active] of params.chatAbortControllers) { if (!sessionKeys.has(active.sessionKey) && !sessionIds.has(active.sessionId)) { continue; } + if ( + agentId && + active.sessionKey === "global" && + resolveStoredGlobalRunAgentId(active.agentId, params.defaultAgentId) !== agentId + ) { + continue; + } matchedSessionRuns += 1; if (canRequesterAbortChatRun(active, params.requester)) { authorizedRuns.push({ runId, sessionKey: active.sessionKey }); @@ -1875,8 +2033,10 @@ async function abortChatRunsForSessionKeyWithPartials(params: { ops: ChatAbortOps; sessionKey: string; sessionKeyAliases?: string[]; + agentId?: string; sessionId?: string; persistSessionKey?: string; + defaultAgentId: string; abortOrigin: AbortOrigin; stopReason?: string; requester: ChatAbortRequester; @@ -1886,6 +2046,8 @@ async function abortChatRunsForSessionKeyWithPartials(params: { chatAbortControllers: params.context.chatAbortControllers, sessionKeys, sessionIds: [params.sessionId], + agentId: params.agentId, + defaultAgentId: params.defaultAgentId, requester: params.requester, }); const { @@ -1894,6 +2056,8 @@ async function abortChatRunsForSessionKeyWithPartials(params: { } = resolveAuthorizedPreRegisteredAgentRunsForSessionKeys({ context: params.context, sessionKeys, + agentId: params.agentId, + defaultAgentId: params.defaultAgentId, requester: params.requester, }); if (authorizedRuns.length === 0 && authorizedPendingAgentRuns.length === 0) { @@ -1952,21 +2116,31 @@ function nextChatSeq(context: { agentRunSeq: Map }, runId: strin } function broadcastChatFinal(params: { - context: Pick; + context: Pick & + Partial>; runId: string; sessionKey: string; + agentId?: string; message?: Record; }) { const seq = nextChatSeq({ agentRunSeq: params.context.agentRunSeq }, params.runId); + const payloadAgentId = params.sessionKey === "global" ? params.agentId : undefined; const payload = { runId: params.runId, sessionKey: params.sessionKey, + ...(payloadAgentId ? { agentId: payloadAgentId } : {}), seq, state: "final" as const, message: projectChatDisplayMessage(params.message), }; params.context.broadcast("chat", payload); - params.context.nodeSendToSession(params.sessionKey, "chat", payload); + sendGlobalAwareNodeChatPayload({ + context: params.context, + sessionKey: params.sessionKey, + agentId: payloadAgentId, + event: "chat", + payload, + }); params.context.agentRunSeq.delete(params.runId); } @@ -1983,39 +2157,92 @@ function isBtwReplyPayload(payload: ReplyPayload | undefined): payload is ReplyP } function broadcastSideResult(params: { - context: Pick; + context: Pick & + Partial>; payload: SideResultPayload; }) { const seq = nextChatSeq({ agentRunSeq: params.context.agentRunSeq }, params.payload.runId); - params.context.broadcast("chat.side_result", { - ...params.payload, - seq, - }); - params.context.nodeSendToSession(params.payload.sessionKey, "chat.side_result", { + const payloadAgentId = + params.payload.sessionKey === "global" ? params.payload.agentId : undefined; + const payload = { ...params.payload, + ...(payloadAgentId ? { agentId: payloadAgentId } : {}), seq, + }; + params.context.broadcast("chat.side_result", payload); + sendGlobalAwareNodeChatPayload({ + context: params.context, + sessionKey: params.payload.sessionKey, + agentId: payloadAgentId, + event: "chat.side_result", + payload, }); } function broadcastChatError(params: { - context: Pick; + context: Pick & + Partial>; runId: string; sessionKey: string; + agentId?: string; errorMessage?: string; }) { const seq = nextChatSeq({ agentRunSeq: params.context.agentRunSeq }, params.runId); + const payloadAgentId = params.sessionKey === "global" ? params.agentId : undefined; const payload = { runId: params.runId, sessionKey: params.sessionKey, + ...(payloadAgentId ? { agentId: payloadAgentId } : {}), seq, state: "error" as const, errorMessage: params.errorMessage, }; params.context.broadcast("chat", payload); - params.context.nodeSendToSession(params.sessionKey, "chat", payload); + sendGlobalAwareNodeChatPayload({ + context: params.context, + sessionKey: params.sessionKey, + agentId: payloadAgentId, + event: "chat", + payload, + }); params.context.agentRunSeq.delete(params.runId); } +function sendGlobalAwareNodeChatPayload(params: { + context: Pick & + Partial>; + sessionKey: string; + agentId?: string; + event: string; + payload: unknown; +}) { + const deliveryKeys = resolveGlobalAwareNodeChatDeliveryKeys({ + cfg: params.context.getRuntimeConfig?.() ?? ({} as OpenClawConfig), + sessionKey: params.sessionKey, + agentId: params.agentId, + }); + for (const deliveryKey of deliveryKeys) { + params.context.nodeSendToSession(deliveryKey, params.event, params.payload); + } +} + +function resolveGlobalAwareNodeChatDeliveryKeys(params: { + cfg: OpenClawConfig; + sessionKey: string; + agentId?: string; +}): string[] { + if (params.sessionKey !== "global") { + return [params.sessionKey]; + } + const defaultAgentId = resolveDefaultAgentId(params.cfg); + const scopedAgentId = params.agentId ?? defaultAgentId; + const keys = [`agent:${scopedAgentId}:global`]; + if (scopedAgentId === defaultAgentId) { + keys.push("global"); + } + return keys; +} + function isSourceReplyTranscriptMirrorPayload(payload: ReplyPayload | undefined) { return Boolean(payload && getReplyPayloadMetadata(payload)?.sourceReplyTranscriptMirror); } @@ -2114,12 +2341,33 @@ export const chatHandlers: GatewayRequestHandlers = { } const { sessionKey, limit, maxChars } = params as { sessionKey: string; + agentId?: string; limit?: number; maxChars?: number; }; - const { cfg, storePath, entry } = loadSessionEntry(sessionKey); + const agentIdOverride = normalizeOptionalText((params as { agentId?: string }).agentId); + const requestedAgentId = resolveRequestedChatAgentId({ + cfg: (context as { getRuntimeConfig?: () => OpenClawConfig }).getRuntimeConfig?.(), + requestedSessionKey: sessionKey, + agentId: agentIdOverride, + }); + const sessionLoadOptions = requestedAgentId ? { agentId: requestedAgentId } : undefined; + const { cfg, storePath, entry } = loadSessionEntry(sessionKey, sessionLoadOptions); + const selectedAgent = validateChatSelectedAgent({ + cfg, + requestedSessionKey: sessionKey, + agentId: requestedAgentId, + }); + if (!selectedAgent.ok) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, selectedAgent.error)); + return; + } const sessionId = entry?.sessionId; - const sessionAgentId = resolveSessionAgentId({ sessionKey, config: cfg }); + const sessionAgentId = resolveSessionAgentId({ + sessionKey, + config: cfg, + agentId: selectedAgent.agentId, + }); const resolvedSessionModel = resolveSessionModelRef(cfg, entry, sessionAgentId); const hardMax = 1000; const defaultLimit = 200; @@ -2167,7 +2415,11 @@ export const chatHandlers: GatewayRequestHandlers = { messages: normalized, maxSingleMessageBytes: perMessageHardCap, }); - scheduleChatHistoryManagedImageCleanup({ sessionKey, context }); + scheduleChatHistoryManagedImageCleanup({ + sessionKey, + ...(selectedAgent.agentId ? { agentId: selectedAgent.agentId } : {}), + context, + }); const capped = capArrayByJsonBytes(replaced.messages, maxHistoryBytes).items; const bounded = enforceChatHistoryFinalBudget({ messages: capped, maxBytes: maxHistoryBytes }); const placeholderCount = replaced.replacedCount + bounded.placeholderCount; @@ -2218,8 +2470,40 @@ export const chatHandlers: GatewayRequestHandlers = { } const { sessionKey: rawSessionKey, runId } = params as { sessionKey: string; + agentId?: string; runId?: string; }; + const agentIdOverride = normalizeOptionalText((params as { agentId?: string }).agentId); + const abortCfg = context.getRuntimeConfig(); + const defaultAgentId = resolveDefaultAgentId(abortCfg); + const parsedAbortSessionKey = parseAgentSessionKey(rawSessionKey); + const abortSessionResolvesGlobal = + resolveSessionStoreKey({ cfg: abortCfg, sessionKey: rawSessionKey }) === "global"; + const inferredGlobalAgentId = + !agentIdOverride && parsedAbortSessionKey && abortSessionResolvesGlobal + ? normalizeAgentId(parsedAbortSessionKey.agentId) + : undefined; + const abortAgentId = + agentIdOverride ?? + inferredGlobalAgentId ?? + (abortSessionResolvesGlobal ? defaultAgentId : undefined); + if ( + agentIdOverride && + parsedAbortSessionKey && + normalizeAgentId(parsedAbortSessionKey.agentId) !== normalizeAgentId(agentIdOverride) + ) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `agentId "${agentIdOverride}" does not match session key "${rawSessionKey}"`, + ), + ); + return; + } + const canonicalAbortSessionKey = + abortAgentId && abortSessionResolvesGlobal ? "global" : rawSessionKey; const ops = createChatAbortOps(context); const requester = resolveChatAbortRequester(client); @@ -2228,7 +2512,10 @@ export const chatHandlers: GatewayRequestHandlers = { const res = await abortChatRunsForSessionKeyWithPartials({ context, ops, - sessionKey: rawSessionKey, + sessionKey: canonicalAbortSessionKey, + sessionKeyAliases: canonicalAbortSessionKey === rawSessionKey ? undefined : [rawSessionKey], + agentId: abortAgentId, + defaultAgentId, abortOrigin: "rpc", stopReason: "rpc", requester, @@ -2240,16 +2527,36 @@ export const chatHandlers: GatewayRequestHandlers = { respond(true, { ok: true, aborted: res.aborted, runIds: res.runIds }); return; } + const normalizedAgentIdOverride = abortAgentId?.toLowerCase(); const active = context.chatAbortControllers.get(runId); if (!active) { const pendingAgentEntry = context.dedupe.get(`agent:${runId}`); - const pendingAgentPayload = readPreRegisteredAgentDedupePayloadForSession({ - entry: pendingAgentEntry, - runId, - sessionKey: rawSessionKey, - }); - if (pendingAgentPayload) { + const pendingAgentMatch = (() => { + const canonicalMatch = readPreRegisteredAgentDedupePayloadForSession({ + entry: pendingAgentEntry, + runId, + sessionKey: canonicalAbortSessionKey, + agentId: abortAgentId, + defaultAgentId, + }); + if (canonicalMatch) { + return { sessionKey: canonicalAbortSessionKey, payload: canonicalMatch }; + } + if (rawSessionKey === canonicalAbortSessionKey) { + return undefined; + } + const aliasMatch = readPreRegisteredAgentDedupePayloadForSession({ + entry: pendingAgentEntry, + runId, + sessionKey: rawSessionKey, + agentId: abortAgentId, + defaultAgentId, + }); + return aliasMatch ? { sessionKey: rawSessionKey, payload: aliasMatch } : undefined; + })(); + if (pendingAgentMatch) { + const pendingAgentPayload = pendingAgentMatch.payload; if (!canRequesterAbortPreRegisteredAgentRun(pendingAgentPayload, requester)) { respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unauthorized")); return; @@ -2257,7 +2564,7 @@ export const chatHandlers: GatewayRequestHandlers = { writePreRegisteredAgentAbort({ context, runId, - sessionKey: rawSessionKey, + sessionKey: pendingAgentMatch.sessionKey, payload: pendingAgentPayload, stopReason: "rpc", }); @@ -2267,8 +2574,9 @@ export const chatHandlers: GatewayRequestHandlers = { respond(true, { ok: true, aborted: false, runIds: [] }); return; } + const abortSessionKeysForRun = new Set([rawSessionKey, canonicalAbortSessionKey]); if ( - active.sessionKey !== rawSessionKey && + !abortSessionKeysForRun.has(active.sessionKey) && !canRequesterAbortChatRunWithoutSessionMatch(active, requester) ) { respond( @@ -2278,6 +2586,18 @@ export const chatHandlers: GatewayRequestHandlers = { ); return; } + if ( + normalizedAgentIdOverride && + active.sessionKey === "global" && + resolveStoredGlobalRunAgentId(active.agentId, defaultAgentId) !== normalizedAgentIdOverride + ) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "runId does not match agentId"), + ); + return; + } if (!canRequesterAbortChatRun(active, requester)) { respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unauthorized")); return; @@ -2297,6 +2617,7 @@ export const chatHandlers: GatewayRequestHandlers = { { runId, sessionId: active.sessionId, + agentId: active.agentId, text: partialText, abortOrigin: "rpc", }, @@ -2323,6 +2644,7 @@ export const chatHandlers: GatewayRequestHandlers = { } const p = params as { sessionKey: string; + agentId?: string; sessionId?: string; message: string; thinking?: string; @@ -2398,13 +2720,20 @@ export const chatHandlers: GatewayRequestHandlers = { return; } const rawSessionKey = p.sessionKey; + const agentIdOverride = normalizeOptionalText(p.agentId); + const requestedAgentId = resolveRequestedChatAgentId({ + cfg: (context as { getRuntimeConfig?: () => OpenClawConfig }).getRuntimeConfig?.(), + requestedSessionKey: rawSessionKey, + agentId: agentIdOverride, + }); + const sessionLoadOptions = requestedAgentId ? { agentId: requestedAgentId } : undefined; const { cfg, entry, canonicalKey: sessionKey, } = measureDiagnosticsTimelineSpanSync( "gateway.chat_send.load_session", - () => loadSessionEntry(rawSessionKey), + () => loadSessionEntry(rawSessionKey, sessionLoadOptions), { phase: "agent-turn", attributes: { @@ -2413,6 +2742,15 @@ export const chatHandlers: GatewayRequestHandlers = { }, }, ); + const selectedAgent = validateChatSelectedAgent({ + cfg, + requestedSessionKey: rawSessionKey, + agentId: requestedAgentId, + }); + if (!selectedAgent.ok) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, selectedAgent.error)); + return; + } const requestedSessionId = normalizeOptionalText(p.sessionId); const backingSessionId = entry?.sessionId ?? requestedSessionId; const deletedAgentId = resolveDeletedAgentIdFromSessionKey(cfg, sessionKey); @@ -2430,6 +2768,12 @@ export const chatHandlers: GatewayRequestHandlers = { const agentId = resolveSessionAgentId({ sessionKey, config: cfg, + agentId: selectedAgent.agentId, + }); + const activeRunScopeKey = resolveChatSendActiveScopeKey({ + sessionKey, + agentId: selectedAgent.agentId, + mainKey: cfg.session?.mainKey, }); const resolvedSessionModel = resolveSessionModelRef(cfg, entry, agentId); const resolvedSessionAuthProvider = resolveProviderIdForAuth(resolvedSessionModel.provider, { @@ -2466,13 +2810,18 @@ export const chatHandlers: GatewayRequestHandlers = { } if (stopCommand) { + const defaultAgentId = resolveDefaultAgentId(cfg); + const stopAgentId = + sessionKey === "global" ? (selectedAgent.agentId ?? defaultAgentId) : selectedAgent.agentId; const res = await abortChatRunsForSessionKeyWithPartials({ context, ops: createChatAbortOps(context), sessionKey: rawSessionKey, sessionKeyAliases: sessionKey === rawSessionKey ? undefined : [sessionKey], + agentId: stopAgentId, sessionId: entry?.sessionId, persistSessionKey: sessionKey, + defaultAgentId, abortOrigin: "stop-command", stopReason: "stop", requester: resolveChatAbortRequester(client), @@ -2516,7 +2865,7 @@ export const chatHandlers: GatewayRequestHandlers = { explicitDeliverRoute: originatingRoute.explicitDeliverRoute, message: rawMessage, originatingChannel: originatingRoute.originatingChannel, - sessionKey, + sessionKey: activeRunScopeKey, }); if (activeChatSendDedupeKey) { const activeRunId = resolveActiveChatSendRunId( @@ -2614,7 +2963,8 @@ export const chatHandlers: GatewayRequestHandlers = { chatAbortControllers: context.chatAbortControllers, runId: clientRunId, sessionId: backingSessionId ?? clientRunId, - sessionKey: rawSessionKey, + sessionKey, + agentId: selectedAgent.agentId, timeoutMs, now, ownerConnId: normalizeOptionalText(client?.connId), @@ -2639,6 +2989,7 @@ export const chatHandlers: GatewayRequestHandlers = { } context.addChatRun(clientRunId, { sessionKey, + agentId: selectedAgent.agentId, clientRunId, }); const ackPayload = { @@ -2712,6 +3063,7 @@ export const chatHandlers: GatewayRequestHandlers = { CommandBody: commandBody, InputProvenance: systemInputProvenance, SessionKey: sessionKey, + AgentId: agentId, Provider: INTERNAL_MESSAGE_CHANNEL, Surface: INTERNAL_MESSAGE_CHANNEL, OriginatingChannel: originatingChannel, @@ -2784,7 +3136,10 @@ export const chatHandlers: GatewayRequestHandlers = { input: baseUserTurnInput, resolveInput: () => userTurnInputPromise, target: () => { - const { storePath: latestStorePath, entry: latestEntry } = loadSessionEntry(sessionKey); + const { storePath: latestStorePath, entry: latestEntry } = loadSessionEntry( + sessionKey, + sessionLoadOptions, + ); const resolvedSessionId = latestEntry?.sessionId ?? backingSessionId; if (!resolvedSessionId) { return undefined; @@ -2840,7 +3195,10 @@ export const chatHandlers: GatewayRequestHandlers = { if (!transcriptPayload) { return; } - const { storePath: latestStorePath, entry: latestEntry } = loadSessionEntry(sessionKey); + const { storePath: latestStorePath, entry: latestEntry } = loadSessionEntry( + sessionKey, + sessionLoadOptions, + ); const sessionId = latestEntry?.sessionId ?? backingSessionId ?? clientRunId; const resolvedTranscriptPath = resolveTranscriptPath({ sessionId, @@ -2854,6 +3212,7 @@ export const chatHandlers: GatewayRequestHandlers = { ); const assistantContent = await buildAssistantDisplayContentFromReplyPayloads({ sessionKey, + agentId, payloads: [transcriptPayload], managedImageLocalRoots: mediaLocalRoots, includeSensitiveMedia: transcriptPayload.sensitiveMedia !== true, @@ -2888,6 +3247,7 @@ export const chatHandlers: GatewayRequestHandlers = { return; } const appended = await appendAssistantTranscriptMessage({ + sessionKey, message: transcriptReply, ...(persistedContentForAppend?.length ? { content: persistedContentForAppend } : {}), sessionId, @@ -2971,8 +3331,22 @@ export const chatHandlers: GatewayRequestHandlers = { // Register for any other active runs *in the same session* so // late-joining clients (e.g. page refresh mid-response) receive // in-progress tool events without leaking cross-session data. + const defaultAgentId = resolveDefaultAgentId(cfg); + const selectedGlobalAgentId = + sessionKey === "global" ? (selectedAgent.agentId ?? defaultAgentId) : undefined; for (const [activeRunId, active] of context.chatAbortControllers) { - if (activeRunId !== runId && active.sessionKey === p.sessionKey) { + const activeGlobalAgentId = + active.sessionKey === "global" + ? (active.agentId ?? defaultAgentId) + : undefined; + const sameSelectedGlobalAgent = + sessionKey === "global" && + selectedGlobalAgentId !== undefined && + activeGlobalAgentId === selectedGlobalAgentId; + const sameSession = + active.sessionKey === sessionKey && + (sessionKey !== "global" || sameSelectedGlobalAgent); + if (activeRunId !== runId && sameSession) { context.registerToolEventRecipient(activeRunId, connId); } } @@ -3055,6 +3429,7 @@ export const chatHandlers: GatewayRequestHandlers = { kind: "btw", runId: clientRunId, sessionKey, + ...(sessionKey === "global" && agentId ? { agentId } : {}), question: btwReplies[0].btw.question.trim(), text: btwText, isError: btwReplies.some((payload) => payload.isError), @@ -3065,6 +3440,7 @@ export const chatHandlers: GatewayRequestHandlers = { context, runId: clientRunId, sessionKey, + agentId, }); } else { await persistGatewayUserTurnTranscriptBestEffort(); @@ -3080,8 +3456,10 @@ export const chatHandlers: GatewayRequestHandlers = { accountId, payloads: rawFinalPayloads, }); - const { storePath: latestStorePath, entry: latestEntry } = - loadSessionEntry(sessionKey); + const { storePath: latestStorePath, entry: latestEntry } = loadSessionEntry( + sessionKey, + sessionLoadOptions, + ); const sessionId = latestEntry?.sessionId ?? backingSessionId ?? clientRunId; const resolvedTranscriptPath = resolveTranscriptPath({ sessionId, @@ -3095,6 +3473,7 @@ export const chatHandlers: GatewayRequestHandlers = { ); const assistantContent = await buildAssistantDisplayContentFromReplyPayloads({ sessionKey, + agentId, payloads: finalPayloads, managedImageLocalRoots: mediaLocalRoots, includeSensitiveMedia: false, @@ -3127,6 +3506,7 @@ export const chatHandlers: GatewayRequestHandlers = { hasSensitiveMedia ? await buildAssistantDisplayContentFromReplyPayloads({ sessionKey, + agentId, payloads: finalPayloads, managedImageLocalRoots: mediaLocalRoots, includeSensitiveMedia: false, @@ -3170,6 +3550,7 @@ export const chatHandlers: GatewayRequestHandlers = { assistantContent?.length ) { const appended = await appendAssistantTranscriptMessage({ + sessionKey, message: transcriptReply, ...(persistedContentForAppend?.length ? { content: persistedContentForAppend } @@ -3226,6 +3607,7 @@ export const chatHandlers: GatewayRequestHandlers = { context, runId: clientRunId, sessionKey, + agentId, message, }); } @@ -3242,8 +3624,10 @@ export const chatHandlers: GatewayRequestHandlers = { accountId, payloads: sourceReplyPayloads, }); - const { storePath: latestStorePath, entry: latestEntry } = - loadSessionEntry(sessionKey); + const { storePath: latestStorePath, entry: latestEntry } = loadSessionEntry( + sessionKey, + sessionLoadOptions, + ); const sessionId = latestEntry?.sessionId ?? backingSessionId ?? clientRunId; const resolvedTranscriptPath = resolveTranscriptPath({ sessionId, @@ -3260,6 +3644,7 @@ export const chatHandlers: GatewayRequestHandlers = { ): Promise => await buildAssistantDisplayContentFromReplyPayloads({ sessionKey, + agentId, payloads, managedImageLocalRoots: mediaLocalRoots, includeSensitiveMedia: false, @@ -3459,6 +3844,7 @@ export const chatHandlers: GatewayRequestHandlers = { const result = await rewriteTranscriptEntriesInSessionFile({ sessionFile: resolvedTranscriptPath, sessionKey, + agentId, config: cfg, request: { allowedRewriteSuffixEntryIds: [...allowedSourceReplyMirrorIds], @@ -3526,6 +3912,7 @@ export const chatHandlers: GatewayRequestHandlers = { context, runId: clientRunId, sessionKey, + agentId, message, }); broadcastedSourceReplyFinal = true; @@ -3539,6 +3926,7 @@ export const chatHandlers: GatewayRequestHandlers = { context, runId: clientRunId, sessionKey, + agentId, errorMessage: returnedAgentErrorMessage, }); } @@ -3603,6 +3991,7 @@ export const chatHandlers: GatewayRequestHandlers = { context, runId: clientRunId, sessionKey, + agentId, errorMessage: String(err), }); }) @@ -3637,6 +4026,7 @@ export const chatHandlers: GatewayRequestHandlers = { context, runId: clientRunId, sessionKey, + agentId, errorMessage: String(err), }); } @@ -3655,26 +4045,53 @@ export const chatHandlers: GatewayRequestHandlers = { } const p = params as { sessionKey: string; + agentId?: string; message: string; label?: string; }; // Load session to find transcript file const rawSessionKey = p.sessionKey; - const { cfg, storePath, entry, canonicalKey: sessionKey } = loadSessionEntry(rawSessionKey); + const requestedAgentId = resolveRequestedChatAgentId({ + cfg: (context as { getRuntimeConfig?: () => OpenClawConfig }).getRuntimeConfig?.(), + requestedSessionKey: rawSessionKey, + agentId: p.agentId, + }); + const sessionLoadOptions = requestedAgentId ? { agentId: requestedAgentId } : undefined; + const { + cfg, + storePath, + entry, + canonicalKey: sessionKey, + } = loadSessionEntry(rawSessionKey, sessionLoadOptions); + const selectedAgent = validateChatSelectedAgent({ + cfg, + requestedSessionKey: rawSessionKey, + agentId: requestedAgentId, + }); + if (!selectedAgent.ok) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, selectedAgent.error)); + return; + } const sessionId = entry?.sessionId; if (!sessionId || !storePath) { respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "session not found")); return; } + const agentId = resolveSessionAgentId({ + sessionKey, + config: cfg, + agentId: selectedAgent.agentId, + }); const appended = await appendAssistantTranscriptMessage({ + sessionKey, message: p.message, label: p.label, sessionId, storePath, sessionFile: entry?.sessionFile, - agentId: resolveSessionAgentId({ sessionKey, config: cfg }), + agentId, createIfMissing: true, cfg, }); @@ -3697,12 +4114,19 @@ export const chatHandlers: GatewayRequestHandlers = { const chatPayload = { runId: `inject-${appended.messageId}`, sessionKey, + ...(sessionKey === "global" && agentId ? { agentId } : {}), seq: 0, state: "final" as const, message, }; context.broadcast("chat", chatPayload); - context.nodeSendToSession(sessionKey, "chat", chatPayload); + sendGlobalAwareNodeChatPayload({ + context, + sessionKey, + agentId, + event: "chat", + payload: chatPayload, + }); respond(true, { ok: true, messageId: appended.messageId }); }, diff --git a/src/gateway/server-methods/sessions.abort-agent-scope.test.ts b/src/gateway/server-methods/sessions.abort-agent-scope.test.ts index 7c525035684c..2b56b7cd53ce 100644 --- a/src/gateway/server-methods/sessions.abort-agent-scope.test.ts +++ b/src/gateway/server-methods/sessions.abort-agent-scope.test.ts @@ -3,6 +3,11 @@ import type { GatewayRequestContext, RespondFn } from "./types.js"; const chatAbortMock = vi.fn(); const resolveSessionKeyForRunMock = vi.fn(); +const listSessionsFromStoreAsyncMock = vi.fn(); +const loadCombinedSessionStoreForGatewayMock = vi.fn(); +const loadSessionEntryMock = vi.fn((sessionKey: string, _opts?: { agentId?: string }) => ({ + canonicalKey: sessionKey, +})); vi.mock("../server-session-key.js", () => ({ resolveSessionKeyForRun: (...args: unknown[]) => resolveSessionKeyForRunMock(...args), @@ -18,18 +23,23 @@ vi.mock("../session-utils.js", async () => { const actual = await vi.importActual("../session-utils.js"); return { ...actual, - loadSessionEntry: (sessionKey: string) => ({ canonicalKey: sessionKey }), + listSessionsFromStoreAsync: (...args: unknown[]) => listSessionsFromStoreAsyncMock(...args), + loadCombinedSessionStoreForGateway: (...args: unknown[]) => + loadCombinedSessionStoreForGatewayMock(...args), + loadSessionEntry: (...args: unknown[]) => + loadSessionEntryMock(...(args as [string, { agentId?: string }?])), }; }); import { sessionsHandlers } from "./sessions.js"; -function createActiveRun(sessionKey: string) { +function createActiveRun(sessionKey: string, params: { agentId?: string } = {}) { const now = Date.now(); return { controller: new AbortController(), sessionId: "sess-active", sessionKey, + agentId: params.agentId, startedAtMs: now, expiresAtMs: now + 30_000, kind: "chat-send" as const, @@ -40,6 +50,14 @@ describe("sessions.abort agent scope", () => { beforeEach(() => { chatAbortMock.mockReset(); resolveSessionKeyForRunMock.mockReset(); + listSessionsFromStoreAsyncMock.mockReset(); + listSessionsFromStoreAsyncMock.mockResolvedValue({ sessions: [] }); + loadCombinedSessionStoreForGatewayMock.mockReset(); + loadCombinedSessionStoreForGatewayMock.mockReturnValue({ + storePath: "/tmp/openclaw-sessions.json", + store: {}, + }); + loadSessionEntryMock.mockClear(); }); it("does not abort an active run whose session key belongs to another requested agent", async () => { @@ -102,7 +120,7 @@ describe("sessions.abort agent scope", () => { }); it("aborts global-scope active runs for non-default agents", async () => { - const activeRun = createActiveRun("global"); + const activeRun = createActiveRun("global", { agentId: "work" }); const context = { chatAbortControllers: new Map([["run-global", activeRun]]), getRuntimeConfig: () => ({ @@ -126,11 +144,282 @@ describe("sessions.abort agent scope", () => { expect(chatAbortMock).toHaveBeenCalledTimes(1); expect(chatAbortMock.mock.calls[0]?.[0]).toEqual( expect.objectContaining({ - params: { sessionKey: "global", runId: "run-global" }, + params: { sessionKey: "global", runId: "run-global", agentId: "work" }, }), ); }); + it("uses the active run agent for key and runId global aborts without agentId", async () => { + const activeRun = createActiveRun("global", { agentId: "work" }); + const context = { + chatAbortControllers: new Map([["run-global", activeRun]]), + getRuntimeConfig: () => ({ + agents: { list: [{ id: "main", default: true }, { id: "work" }] }, + session: { scope: "global" }, + }), + } as unknown as GatewayRequestContext; + resolveSessionKeyForRunMock.mockReturnValue(undefined); + const respond = vi.fn() as unknown as RespondFn; + + await sessionsHandlers["sessions.abort"]({ + req: { id: "req-global-key-run" } as never, + params: { key: "global", runId: "run-global" }, + respond, + context, + client: null, + isWebchatConnect: () => false, + }); + + expect(resolveSessionKeyForRunMock).not.toHaveBeenCalled(); + expect(chatAbortMock).toHaveBeenCalledTimes(1); + expect(chatAbortMock.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ + params: { sessionKey: "global", runId: "run-global", agentId: "work" }, + }), + ); + }); + + it("emits selected global abort changes with agent scope", async () => { + const activeRun = createActiveRun("global", { agentId: "work" }); + const broadcastToConnIds = vi.fn(); + chatAbortMock.mockImplementationOnce( + async ({ respond: abortRespond }: { respond: RespondFn }) => { + abortRespond(true, { ok: true, aborted: true, runIds: ["run-global"] }); + }, + ); + const context = { + chatAbortControllers: new Map([["run-global", activeRun]]), + getRuntimeConfig: () => ({ + agents: { list: [{ id: "main", default: true }, { id: "work" }] }, + session: { scope: "global" }, + }), + getSessionEventSubscriberConnIds: () => new Set(["conn-1"]), + broadcastToConnIds, + dedupe: new Map(), + } as unknown as GatewayRequestContext; + resolveSessionKeyForRunMock.mockReturnValue(undefined); + const respond = vi.fn() as unknown as RespondFn; + + await sessionsHandlers["sessions.abort"]({ + req: { id: "req-global-abort-event" } as never, + params: { key: "global", runId: "run-global" }, + respond, + context, + client: null, + isWebchatConnect: () => false, + }); + + expect(broadcastToConnIds).toHaveBeenCalledWith( + "sessions.changed", + expect.objectContaining({ + sessionKey: "global", + agentId: "work", + reason: "abort", + }), + new Set(["conn-1"]), + { dropIfSlow: true }, + ); + }); + + it("forwards selected-agent scope for key-based global aborts", async () => { + const context = { + chatAbortControllers: new Map(), + getRuntimeConfig: () => ({ + agents: { list: [{ id: "main", default: true }, { id: "work" }] }, + session: { scope: "global" }, + }), + } as unknown as GatewayRequestContext; + const respond = vi.fn() as unknown as RespondFn; + + await sessionsHandlers["sessions.abort"]({ + req: { id: "req-global-key" } as never, + params: { key: "global", agentId: "work" }, + respond, + context, + client: null, + isWebchatConnect: () => false, + }); + + expect(chatAbortMock).toHaveBeenCalledTimes(1); + expect(chatAbortMock.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ + params: { sessionKey: "global", runId: undefined, agentId: "work" }, + }), + ); + }); + + it("infers selected-agent global aborts from agent-prefixed aliases", async () => { + loadSessionEntryMock.mockImplementationOnce(() => ({ canonicalKey: "global" })); + const context = { + chatAbortControllers: new Map(), + getRuntimeConfig: () => ({ + agents: { list: [{ id: "main", default: true }, { id: "work" }] }, + session: { scope: "global" }, + }), + } as unknown as GatewayRequestContext; + const respond = vi.fn() as unknown as RespondFn; + + await sessionsHandlers["sessions.abort"]({ + req: { id: "req-global-key-alias" } as never, + params: { key: "agent:work:main" }, + respond, + context, + client: null, + isWebchatConnect: () => false, + }); + + expect(loadSessionEntryMock).toHaveBeenCalledWith("agent:work:main", { agentId: "work" }); + expect(chatAbortMock).toHaveBeenCalledTimes(1); + expect(chatAbortMock.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ + params: { sessionKey: "global", runId: undefined, agentId: "work" }, + }), + ); + }); + + it("marks selected-agent global session rows active only for their own agent", async () => { + const activeRun = createActiveRun("global", { agentId: "main" }); + const context = { + chatAbortControllers: new Map([["run-main-global", activeRun]]), + getRuntimeConfig: () => ({ + agents: { list: [{ id: "main", default: true }, { id: "work" }] }, + session: { scope: "global" }, + }), + loadGatewayModelCatalog: vi.fn().mockResolvedValue([]), + } as unknown as GatewayRequestContext; + listSessionsFromStoreAsyncMock.mockResolvedValue({ + sessions: [{ key: "global", hasActiveRun: false }], + }); + const respond = vi.fn() as unknown as RespondFn; + + await sessionsHandlers["sessions.list"]({ + req: { id: "req-list-global" } as never, + params: { includeGlobal: true, agentId: "work" }, + respond, + context, + client: null, + isWebchatConnect: () => false, + }); + + expect(respond).toHaveBeenCalledWith( + true, + expect.objectContaining({ + sessions: [expect.objectContaining({ key: "global", hasActiveRun: false })], + }), + undefined, + ); + }); + + it("marks unscoped global runs active for the configured default agent", async () => { + const activeRun = createActiveRun("global"); + const context = { + chatAbortControllers: new Map([["run-default-global", activeRun]]), + getRuntimeConfig: () => ({ + agents: { list: [{ id: "main", default: true }, { id: "work" }] }, + session: { scope: "global" }, + }), + loadGatewayModelCatalog: vi.fn().mockResolvedValue([]), + } as unknown as GatewayRequestContext; + listSessionsFromStoreAsyncMock.mockResolvedValue({ + sessions: [{ key: "global", hasActiveRun: false }], + }); + const respond = vi.fn() as unknown as RespondFn; + + await sessionsHandlers["sessions.list"]({ + req: { id: "req-list-default-global" } as never, + params: { includeGlobal: true, agentId: "main" }, + respond, + context, + client: null, + isWebchatConnect: () => false, + }); + + expect(respond).toHaveBeenCalledWith( + true, + expect.objectContaining({ + sessions: [expect.objectContaining({ key: "global", hasActiveRun: true })], + }), + undefined, + ); + }); + + it("subscribes selected-agent global message events on an agent-scoped key", async () => { + const subscribeSessionMessageEvents = vi.fn(); + const context = { + subscribeSessionMessageEvents, + getRuntimeConfig: () => ({ + agents: { list: [{ id: "main", default: true }, { id: "work" }] }, + session: { scope: "global" }, + }), + } as unknown as GatewayRequestContext; + const respond = vi.fn() as unknown as RespondFn; + + await sessionsHandlers["sessions.messages.subscribe"]({ + req: { id: "req-sub-global" } as never, + params: { key: "global", agentId: "work" }, + respond, + context, + client: { connId: "conn-work" } as never, + isWebchatConnect: () => false, + }); + + expect(subscribeSessionMessageEvents).toHaveBeenCalledWith("conn-work", "agent:work:global"); + expect(respond).toHaveBeenCalledWith(true, { subscribed: true, key: "global" }, undefined); + }); + + it("subscribes bare global message events on the configured default agent key", async () => { + const subscribeSessionMessageEvents = vi.fn(); + const context = { + subscribeSessionMessageEvents, + getRuntimeConfig: () => ({ + agents: { list: [{ id: "main" }, { id: "work", default: true }] }, + session: { scope: "global" }, + }), + } as unknown as GatewayRequestContext; + const respond = vi.fn() as unknown as RespondFn; + + await sessionsHandlers["sessions.messages.subscribe"]({ + req: { id: "req-sub-global-default" } as never, + params: { key: "global" }, + respond, + context, + client: { connId: "conn-default" } as never, + isWebchatConnect: () => false, + }); + + expect(subscribeSessionMessageEvents).toHaveBeenCalledWith("conn-default", "agent:work:global"); + expect(respond).toHaveBeenCalledWith(true, { subscribed: true, key: "global" }, undefined); + }); + + it("infers selected-agent global subscriptions from agent-prefixed aliases", async () => { + loadSessionEntryMock.mockImplementationOnce(() => ({ canonicalKey: "global" })); + const subscribeSessionMessageEvents = vi.fn(); + const context = { + subscribeSessionMessageEvents, + getRuntimeConfig: () => ({ + agents: { list: [{ id: "main", default: true }, { id: "work" }] }, + session: { scope: "global" }, + }), + } as unknown as GatewayRequestContext; + const respond = vi.fn() as unknown as RespondFn; + + await sessionsHandlers["sessions.messages.subscribe"]({ + req: { id: "req-sub-global-alias" } as never, + params: { key: "agent:work:main" }, + respond, + context, + client: { connId: "conn-work-alias" } as never, + isWebchatConnect: () => false, + }); + + expect(loadSessionEntryMock).toHaveBeenCalledWith("agent:work:main", { agentId: "work" }); + expect(subscribeSessionMessageEvents).toHaveBeenCalledWith( + "conn-work-alias", + "agent:work:global", + ); + expect(respond).toHaveBeenCalledWith(true, { subscribed: true, key: "global" }, undefined); + }); + it("aborts an active legacy-key run owned by the configured default agent", async () => { const activeRun = createActiveRun("main"); const context = { @@ -188,6 +477,100 @@ describe("sessions.abort agent scope", () => { ); }); + it("rejects explicit agentId mismatches before session mutations", async () => { + const context = { + getRuntimeConfig: () => ({ + agents: { list: [{ id: "main", default: true }, { id: "work" }] }, + session: { scope: "global" }, + }), + } as unknown as GatewayRequestContext; + + for (const [method, params] of [ + ["sessions.patch", { key: "agent:main:main", agentId: "work", label: "Work" }], + ["sessions.delete", { key: "agent:main:main", agentId: "work" }], + ["sessions.compact", { key: "agent:main:main", agentId: "work" }], + ] as const) { + const respond = vi.fn() as unknown as RespondFn; + + await sessionsHandlers[method]({ + req: { id: `req-${method}` } as never, + params, + respond, + context, + client: null, + isWebchatConnect: () => false, + }); + + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ + message: "session key agent does not match agentId", + }), + ); + } + }); + + it("rejects unknown explicit agentId before session mutations", async () => { + const context = { + getRuntimeConfig: () => ({ + agents: { list: [{ id: "main", default: true }] }, + }), + } as unknown as GatewayRequestContext; + const respond = vi.fn() as unknown as RespondFn; + + await sessionsHandlers["sessions.patch"]({ + req: { id: "req-unknown-agent-patch" } as never, + params: { key: "global", agentId: "work", label: "Work" }, + respond, + context, + client: null, + isWebchatConnect: () => false, + }); + + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ + message: 'Unknown agent id "work"', + }), + ); + }); + + it("rejects unknown inferred selected-global aliases before session mutations", async () => { + const context = { + getRuntimeConfig: () => ({ + agents: { list: [{ id: "main", default: true }, { id: "work" }] }, + session: { scope: "global" }, + }), + } as unknown as GatewayRequestContext; + + for (const [method, params] of [ + ["sessions.patch", { key: "agent:typo:main", label: "Typo" }], + ["sessions.delete", { key: "agent:typo:main" }], + ["sessions.compact", { key: "agent:typo:main" }], + ] as const) { + const respond = vi.fn() as unknown as RespondFn; + + await sessionsHandlers[method]({ + req: { id: `req-${method}-unknown-alias` } as never, + params, + respond, + context, + client: null, + isWebchatConnect: () => false, + }); + + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ + message: 'Unknown agent id "typo"', + }), + ); + } + }); + it("applies agentId to legacy key-based abort aliases", async () => { const context = { chatAbortControllers: new Map(), diff --git a/src/gateway/server-methods/sessions.send-followup-status.test.ts b/src/gateway/server-methods/sessions.send-followup-status.test.ts index 61fda04d9648..aee43947e6ee 100644 --- a/src/gateway/server-methods/sessions.send-followup-status.test.ts +++ b/src/gateway/server-methods/sessions.send-followup-status.test.ts @@ -126,4 +126,55 @@ describe("sessions.send completed subagent follow-up status", () => { childSessionKey, }); }); + + for (const method of ["sessions.send", "sessions.steer"] as const) { + it(`${method} passes selected-global agent scope through chat.send`, async () => { + const cfg = { agents: { list: [{ id: "main", default: true }, { id: "work" }] } }; + loadSessionEntryMock.mockReturnValue({ + cfg, + canonicalKey: "global", + storePath: "/tmp/work/sessions.json", + entry: { sessionId: "sess-work-global" }, + }); + readSessionMessagesMock.mockReturnValue([]); + loadGatewaySessionRowMock.mockReturnValue(null); + chatSendMock.mockImplementation(async ({ respond }: { respond: RespondFn }) => { + respond(true, { runId: "run-work", status: "started" }, undefined, undefined); + }); + + const respondMock = vi.fn(); + const respond = respondMock as unknown as RespondFn; + const context = { + chatAbortControllers: new Map(), + broadcastToConnIds: vi.fn(), + getSessionEventSubscriberConnIds: () => new Set(), + getRuntimeConfig: () => cfg, + } as unknown as GatewayRequestContext; + + await sessionsHandlers[method]({ + req: { id: "req-1" } as never, + params: { + key: "global", + agentId: "work", + message: "follow-up", + idempotencyKey: "run-work", + }, + respond, + context, + client: null, + isWebchatConnect: () => false, + }); + + expect(loadSessionEntryMock).toHaveBeenCalledWith("global", { agentId: "work" }); + const chatSendCall = chatSendMock.mock.calls.at(0)?.[0] as + | { params?: Record } + | undefined; + expect(chatSendCall?.params).toMatchObject({ + sessionKey: "global", + agentId: "work", + message: "follow-up", + }); + expect(respondMock.mock.calls.at(0)?.[0]).toBe(true); + }); + } }); diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index 7dd97e0791ce..8a8df78ea2ec 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -27,7 +27,11 @@ import { validateSessionsSendParams, } from "../../../packages/gateway-protocol/src/index.js"; import { resolveModelAgentRuntimeMetadata } from "../../agents/agent-runtime-metadata.js"; -import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { + listAgentIds, + resolveAgentWorkspaceDir, + resolveDefaultAgentId, +} from "../../agents/agent-scope.js"; import { abortEmbeddedAgentRun, isEmbeddedAgentRunActive, @@ -271,8 +275,16 @@ function rejectPluginRuntimeDeleteMismatch(params: { return true; } -function resolveGatewaySessionTargetFromKey(key: string, cfg: OpenClawConfig) { - const target = resolveGatewaySessionStoreTarget({ cfg, key }); +function resolveGatewaySessionTargetFromKey( + key: string, + cfg: OpenClawConfig, + opts?: { agentId?: string }, +) { + const target = resolveGatewaySessionStoreTarget({ + cfg, + key, + ...(opts?.agentId ? { agentId: opts.agentId } : {}), + }); return { cfg, target, storePath: target.storePath }; } @@ -303,15 +315,27 @@ function shouldAttachPendingMessageSeq(params: { payload: unknown; cached?: bool function emitSessionsChanged( context: Pick< GatewayRequestContext, - "broadcastToConnIds" | "chatAbortControllers" | "getSessionEventSubscriberConnIds" + | "broadcastToConnIds" + | "chatAbortControllers" + | "getRuntimeConfig" + | "getSessionEventSubscriberConnIds" >, - payload: { sessionKey?: string; reason: string; compacted?: boolean }, + payload: { sessionKey?: string; agentId?: string; reason: string; compacted?: boolean }, ) { const connIds = context.getSessionEventSubscriberConnIds(); if (connIds.size === 0) { return; } - const sessionRow = payload.sessionKey ? loadGatewaySessionRow(payload.sessionKey) : null; + const sessionRow = payload.sessionKey + ? loadGatewaySessionRow( + payload.sessionKey, + payload.sessionKey === "global" && payload.agentId + ? { agentId: payload.agentId } + : undefined, + ) + : null; + const omitUnscopedGlobalGoal = payload.sessionKey === "global" && !payload.agentId; + const defaultAgentId = resolveDefaultAgentId(context.getRuntimeConfig()); context.broadcastToConnIds( "sessions.changed", { @@ -357,6 +381,7 @@ function emitSessionsChanged( lastThreadId: sessionRow.lastThreadId, totalTokens: sessionRow.totalTokens, totalTokensFresh: sessionRow.totalTokensFresh, + ...(omitUnscopedGlobalGoal ? {} : { goal: sessionRow.goal ?? null }), contextTokens: sessionRow.contextTokens, estimatedCostUsd: sessionRow.estimatedCostUsd, responseUsage: sessionRow.responseUsage, @@ -367,6 +392,8 @@ function emitSessionsChanged( context, requestedKey: payload.sessionKey ?? sessionRow.key, canonicalKey: sessionRow.key, + agentId: sessionRow.key === "global" ? payload.agentId : undefined, + defaultAgentId, }), startedAt: sessionRow.startedAt, endedAt: sessionRow.endedAt, @@ -693,12 +720,17 @@ function resolveScopedAbortKey(params: { }); } -function collectTrackedActiveSessionRunKeys( +type TrackedActiveSessionRun = { + sessionKey: string; + agentId?: string; +}; + +function collectTrackedActiveSessionRuns( context: Partial>, -): Set { - const keys = new Set(); +): TrackedActiveSessionRun[] { + const runs: TrackedActiveSessionRun[] = []; if (!(context.chatAbortControllers instanceof Map)) { - return keys; + return runs; } for (const active of context.chatAbortControllers.values()) { if ( @@ -706,19 +738,124 @@ function collectTrackedActiveSessionRunKeys( typeof active.sessionKey === "string" && active.sessionKey.trim() ) { - keys.add(active.sessionKey); + runs.push({ + sessionKey: active.sessionKey, + agentId: typeof active.agentId === "string" ? normalizeAgentId(active.agentId) : undefined, + }); } } - return keys; + return runs; +} + +function isTrackedActiveSessionRunForKey( + active: TrackedActiveSessionRun, + key: string, + agentId?: string, + defaultAgentId?: string, +): boolean { + if (active.sessionKey !== key) { + return false; + } + if (key !== "global" || agentId === undefined) { + return true; + } + const activeAgentId = active.agentId ?? defaultAgentId; + return activeAgentId ? normalizeAgentId(activeAgentId) === normalizeAgentId(agentId) : false; } function hasTrackedActiveSessionRun(params: { context: Partial>; requestedKey: string; canonicalKey: string; + agentId?: string; + defaultAgentId?: string; }): boolean { - const activeSessionKeys = collectTrackedActiveSessionRunKeys(params.context); - return activeSessionKeys.has(params.canonicalKey) || activeSessionKeys.has(params.requestedKey); + const activeRuns = collectTrackedActiveSessionRuns(params.context); + return activeRuns.some( + (active) => + isTrackedActiveSessionRunForKey( + active, + params.canonicalKey, + params.agentId, + params.defaultAgentId, + ) || + isTrackedActiveSessionRunForKey( + active, + params.requestedKey, + params.agentId, + params.defaultAgentId, + ), + ); +} + +function resolveSessionMessageSubscriptionKey(params: { + canonicalKey: string; + agentId?: string; + defaultAgentId?: string; +}): string { + const agentId = params.agentId + ? normalizeAgentId(params.agentId) + : params.canonicalKey === "global" && params.defaultAgentId + ? normalizeAgentId(params.defaultAgentId) + : undefined; + return params.canonicalKey === "global" && agentId + ? `agent:${agentId}:global` + : params.canonicalKey; +} + +type RequestedGlobalAgentIdResolution = + | { ok: true; agentId?: string } + | { ok: false; error: ReturnType }; + +function resolveRequestedGlobalAgentId( + cfg: OpenClawConfig, + key: string, + explicitAgentId?: string, +): RequestedGlobalAgentIdResolution { + const canonicalKey = resolveSessionStoreKey({ cfg, sessionKey: key }); + const parsed = parseAgentSessionKey(key); + const requestedAgentId = normalizeOptionalString(explicitAgentId); + if (requestedAgentId) { + const agentId = normalizeAgentId(requestedAgentId); + if (!listAgentIds(cfg).includes(agentId)) { + return { + ok: false, + error: errorShape(ErrorCodes.INVALID_REQUEST, `Unknown agent id "${explicitAgentId}"`), + }; + } + if (parsed?.agentId && normalizeAgentId(parsed.agentId) !== agentId) { + return { + ok: false, + error: errorShape(ErrorCodes.INVALID_REQUEST, "session key agent does not match agentId"), + }; + } + if (canonicalKey !== "global") { + const keyAgentId = parsed?.agentId + ? normalizeAgentId(parsed.agentId) + : normalizeAgentId(resolveSessionStoreAgentId(cfg, canonicalKey)); + if (keyAgentId !== agentId) { + return { + ok: false, + error: errorShape(ErrorCodes.INVALID_REQUEST, "session key agent does not match agentId"), + }; + } + } + return { ok: true, agentId }; + } + if (!parsed?.agentId) { + return { ok: true }; + } + const inferredAgentId = normalizeAgentId(parsed.agentId); + if (canonicalKey === "global" && !listAgentIds(cfg).includes(inferredAgentId)) { + return { + ok: false, + error: errorShape(ErrorCodes.INVALID_REQUEST, `Unknown agent id "${parsed.agentId}"`), + }; + } + return { + ok: true, + agentId: canonicalKey === "global" ? inferredAgentId : undefined, + }; } async function interruptSessionRunIfActive(params: { @@ -728,12 +865,16 @@ async function interruptSessionRunIfActive(params: { isWebchatConnect: GatewayRequestHandlerOptions["isWebchatConnect"]; requestedKey: string; canonicalKey: string; + agentId?: string; sessionId?: string; }): Promise<{ interrupted: boolean; error?: ReturnType }> { + const cfg = params.context.getRuntimeConfig(); const hasTrackedRun = hasTrackedActiveSessionRun({ context: params.context, requestedKey: params.requestedKey, canonicalKey: params.canonicalKey, + agentId: params.agentId, + defaultAgentId: resolveDefaultAgentId(cfg), }); const hasEmbeddedRun = typeof params.sessionId === "string" && params.sessionId @@ -757,6 +898,7 @@ async function interruptSessionRunIfActive(params: { req: params.req, params: { sessionKey: abortSessionKey, + ...(params.canonicalKey === "global" && params.agentId ? { agentId: params.agentId } : {}), }, respond: (ok, _payload, error) => { abortOk = ok; @@ -818,8 +960,18 @@ async function handleSessionSend(params: { if (!key) { return; } - const loaded = loadSessionEntry(key); - const { cfg } = loaded; + const cfg = params.context.getRuntimeConfig(); + const requestedAgent = resolveRequestedGlobalAgentId( + cfg, + key, + (p as { agentId?: string }).agentId, + ); + if (!requestedAgent.ok) { + params.respond(false, undefined, requestedAgent.error); + return; + } + const requestedAgentId = requestedAgent.agentId; + const loaded = loadSessionEntry(key, { agentId: requestedAgentId }); let { entry, canonicalKey, storePath } = loaded; // Reject sends/steers targeting sessions whose owning agent was deleted (#65524). const deletedAgentId = resolveDeletedAgentIdFromSessionKey(cfg, canonicalKey); @@ -868,6 +1020,7 @@ async function handleSessionSend(params: { isWebchatConnect: params.isWebchatConnect, requestedKey: key, canonicalKey, + agentId: requestedAgentId, sessionId: entry.sessionId, }); if (interruptResult.error) { @@ -892,6 +1045,7 @@ async function handleSessionSend(params: { req: params.req, params: { sessionKey: canonicalKey, + ...(canonicalKey === "global" && requestedAgentId ? { agentId: requestedAgentId } : {}), message: (p as { message: string }).message, thinking: (p as { thinking?: string }).thinking, attachments: (p as { attachments?: unknown[] }).attachments, @@ -946,6 +1100,7 @@ async function handleSessionSend(params: { } emitSessionsChanged(params.context, { sessionKey: canonicalKey, + ...(canonicalKey === "global" && requestedAgentId ? { agentId: requestedAgentId } : {}), reason: interruptedActiveRun ? "steer" : "send", }); } @@ -1008,10 +1163,17 @@ export const sessionsHandlers: GatewayRequestHandlers = { const sessions = measureDiagnosticsTimelineSpanSync( "gateway.sessions.list.active_run_flags", () => { - const activeSessionKeys = collectTrackedActiveSessionRunKeys(context); + const activeRuns = collectTrackedActiveSessionRuns(context); return result.sessions.map((session) => Object.assign({}, session, { - hasActiveRun: activeSessionKeys.has(session.key), + hasActiveRun: activeRuns.some((active) => + isTrackedActiveSessionRunForKey( + active, + session.key, + session.key === "global" ? p.agentId : undefined, + resolveDefaultAgentId(cfg), + ), + ), }), ); }, @@ -1043,16 +1205,17 @@ export const sessionsHandlers: GatewayRequestHandlers = { if (!assertValidParams(params, validateSessionsCleanupParams, "sessions.cleanup", respond)) { return; } + const p = params; try { const { mode, appliedSummaries } = await runSessionsCleanup({ cfg: context.getRuntimeConfig(), opts: { - agent: params.agent, - allAgents: params.allAgents, - enforce: params.enforce, - activeKey: params.activeKey, - fixMissing: params.fixMissing, - fixDmScope: params.fixDmScope, + agent: p.agent, + allAgents: p.allAgents, + enforce: p.enforce, + activeKey: p.activeKey, + fixMissing: p.fixMissing, + fixDmScope: p.fixDmScope, }, }); const result = serializeSessionCleanupResult({ @@ -1102,13 +1265,26 @@ export const sessionsHandlers: GatewayRequestHandlers = { return; } const connId = client?.connId?.trim(); - const key = requireSessionKey((params as { key?: unknown }).key, respond); + const p = params; + const key = requireSessionKey(p.key, respond); if (!key) { return; } - const { canonicalKey } = loadSessionEntry(key); + const cfg = context.getRuntimeConfig(); + const requestedAgent = resolveRequestedGlobalAgentId(cfg, key, p.agentId); + if (!requestedAgent.ok) { + respond(false, undefined, requestedAgent.error); + return; + } + const requestedAgentId = requestedAgent.agentId; + const { canonicalKey } = loadSessionEntry(key, { agentId: requestedAgentId }); + const subscriptionKey = resolveSessionMessageSubscriptionKey({ + canonicalKey, + agentId: requestedAgentId, + defaultAgentId: resolveDefaultAgentId(cfg), + }); if (connId) { - context.subscribeSessionMessageEvents(connId, canonicalKey); + context.subscribeSessionMessageEvents(connId, subscriptionKey); respond(true, { subscribed: true, key: canonicalKey }, undefined); return; } @@ -1126,13 +1302,26 @@ export const sessionsHandlers: GatewayRequestHandlers = { return; } const connId = client?.connId?.trim(); - const key = requireSessionKey((params as { key?: unknown }).key, respond); + const p = params; + const key = requireSessionKey(p.key, respond); if (!key) { return; } - const { canonicalKey } = loadSessionEntry(key); + const cfg = context.getRuntimeConfig(); + const requestedAgent = resolveRequestedGlobalAgentId(cfg, key, p.agentId); + if (!requestedAgent.ok) { + respond(false, undefined, requestedAgent.error); + return; + } + const requestedAgentId = requestedAgent.agentId; + const { canonicalKey } = loadSessionEntry(key, { agentId: requestedAgentId }); + const subscriptionKey = resolveSessionMessageSubscriptionKey({ + canonicalKey, + agentId: requestedAgentId, + defaultAgentId: resolveDefaultAgentId(cfg), + }); if (connId) { - context.unsubscribeSessionMessageEvents(connId, canonicalKey); + context.unsubscribeSessionMessageEvents(connId, subscriptionKey); } respond(true, { subscribed: false, key: canonicalKey }, undefined); }, @@ -1202,7 +1391,8 @@ export const sessionsHandlers: GatewayRequestHandlers = { if (!assertValidParams(params, validateSessionsDescribeParams, "sessions.describe", respond)) { return; } - const key = requireSessionKey(params.key, respond); + const p = params; + const key = requireSessionKey(p.key, respond); if (!key) { return; } @@ -1220,8 +1410,8 @@ export const sessionsHandlers: GatewayRequestHandlers = { store, key: target.canonicalKey, entry, - includeDerivedTitles: params.includeDerivedTitles, - includeLastMessage: params.includeLastMessage, + includeDerivedTitles: p.includeDerivedTitles, + includeLastMessage: p.includeLastMessage, transcriptUsageMaxBytes: 64 * 1024, }); respond(true, { session: row }, undefined); @@ -1240,7 +1430,7 @@ export const sessionsHandlers: GatewayRequestHandlers = { } respond(true, { ok: true, key: resolved.key }, undefined); }, - "sessions.compaction.list": ({ params, respond }) => { + "sessions.compaction.list": ({ params, respond, context }) => { if ( !assertValidParams( params, @@ -1251,11 +1441,20 @@ export const sessionsHandlers: GatewayRequestHandlers = { ) { return; } - const key = requireSessionKey((params as { key?: unknown }).key, respond); + const p = params; + const key = requireSessionKey(p.key, respond); if (!key) { return; } - const { entry, canonicalKey } = loadSessionEntry(key); + const cfg = context.getRuntimeConfig(); + const requestedAgent = resolveRequestedGlobalAgentId(cfg, key, p.agentId); + if (!requestedAgent.ok) { + respond(false, undefined, requestedAgent.error); + return; + } + const { entry, canonicalKey } = loadSessionEntry(key, { + agentId: requestedAgent.agentId, + }); respond( true, { @@ -1266,7 +1465,7 @@ export const sessionsHandlers: GatewayRequestHandlers = { undefined, ); }, - "sessions.compaction.get": ({ params, respond }) => { + "sessions.compaction.get": ({ params, respond, context }) => { if ( !assertValidParams( params, @@ -1287,7 +1486,15 @@ export const sessionsHandlers: GatewayRequestHandlers = { respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "checkpointId required")); return; } - const { entry, canonicalKey } = loadSessionEntry(key); + const cfg = context.getRuntimeConfig(); + const requestedAgent = resolveRequestedGlobalAgentId(cfg, key, p.agentId); + if (!requestedAgent.ok) { + respond(false, undefined, requestedAgent.error); + return; + } + const { entry, canonicalKey } = loadSessionEntry(key, { + agentId: requestedAgent.agentId, + }); const checkpoint = getSessionCompactionCheckpoint({ entry, checkpointId }); if (!checkpoint) { respond( @@ -1334,8 +1541,25 @@ export const sessionsHandlers: GatewayRequestHandlers = { const parentSessionKey = normalizeOptionalString(p.parentSessionKey); let canonicalParentSessionKey: string | undefined; let parentSessionEntry: SessionEntry | undefined; + let parentSelectedAgentId: string | undefined; if (parentSessionKey) { - const parent = loadSessionEntry(parentSessionKey); + const parentCanonicalKey = resolveSessionStoreKey({ cfg, sessionKey: parentSessionKey }); + if (parentCanonicalKey === "global") { + const parentRequestedAgent = resolveRequestedGlobalAgentId( + cfg, + parentSessionKey, + p.agentId, + ); + if (!parentRequestedAgent.ok) { + respond(false, undefined, parentRequestedAgent.error); + return; + } + parentSelectedAgentId = parentRequestedAgent.agentId; + } + const parent = loadSessionEntry( + parentSessionKey, + parentSelectedAgentId ? { agentId: parentSelectedAgentId } : undefined, + ); if (!parent.entry?.sessionId) { respond( false, @@ -1355,13 +1579,18 @@ export const sessionsHandlers: GatewayRequestHandlers = { cfg.session?.dmScope === "main" ) { const parentAgentId = normalizeAgentId( - resolveAgentIdFromSessionKey(canonicalParentSessionKey) ?? resolveDefaultAgentId(cfg), + parentSelectedAgentId ?? + resolveAgentIdFromSessionKey(canonicalParentSessionKey) ?? + resolveDefaultAgentId(cfg), ); const parentMainKey = resolveAgentMainSessionKey({ cfg, agentId: parentAgentId }); if (canonicalParentSessionKey === parentMainKey) { const { performGatewaySessionReset } = await loadSessionsRuntimeModule(); const resetResult = await performGatewaySessionReset({ key: canonicalParentSessionKey, + ...(canonicalParentSessionKey === "global" && parentSelectedAgentId + ? { agentId: parentSelectedAgentId } + : {}), reason: "new", commandSource: "webchat", }); @@ -1382,15 +1611,21 @@ export const sessionsHandlers: GatewayRequestHandlers = { ); emitSessionsChanged(context, { sessionKey: resetResult.key, + ...(resetResult.key === "global" ? { agentId: resetResult.agentId } : {}), reason: "new", }); return; } } if (canonicalParentSessionKey && p.emitCommandHooks === true) { - const { entry: parentEntry } = loadSessionEntry(canonicalParentSessionKey); + const { entry: parentEntry } = loadSessionEntry( + canonicalParentSessionKey, + parentSelectedAgentId ? { agentId: parentSelectedAgentId } : undefined, + ); const parentAgentId = normalizeAgentId( - resolveAgentIdFromSessionKey(canonicalParentSessionKey) ?? resolveDefaultAgentId(cfg), + parentSelectedAgentId ?? + resolveAgentIdFromSessionKey(canonicalParentSessionKey) ?? + resolveDefaultAgentId(cfg), ); const workspaceDir = resolveAgentWorkspaceDir(cfg, parentAgentId); if (hasInternalHookListeners("command", "new")) { @@ -1406,6 +1641,9 @@ export const sessionsHandlers: GatewayRequestHandlers = { const parentTarget = resolveGatewaySessionStoreTarget({ cfg, key: canonicalParentSessionKey, + ...(canonicalParentSessionKey === "global" && parentSelectedAgentId + ? { agentId: parentSelectedAgentId } + : {}), }); const { emitGatewayBeforeResetPluginHook } = await loadSessionsRuntimeModule(); await emitGatewayBeforeResetPluginHook({ @@ -1427,13 +1665,14 @@ export const sessionsHandlers: GatewayRequestHandlers = { mainKey: cfg.session?.mainKey, }) : buildDashboardSessionKey(agentId); - const target = resolveGatewaySessionStoreTarget({ cfg, key }); - const targetAgentId = resolveAgentIdFromSessionKey(target.canonicalKey); + const target = resolveGatewaySessionStoreTarget({ cfg, key, agentId }); + const targetAgentId = target.agentId; const created = await updateSessionStore(target.storePath, async (store) => { const patched = await applySessionsPatchToStore({ cfg, store, storeKey: target.canonicalKey, + agentId: targetAgentId, patch: { key: target.canonicalKey, label: normalizeOptionalString(p.label), @@ -1516,6 +1755,7 @@ export const sessionsHandlers: GatewayRequestHandlers = { req, params: { sessionKey: target.canonicalKey, + ...(target.canonicalKey === "global" ? { agentId: target.agentId } : {}), message: initialMessage, idempotencyKey: randomUUID(), }, @@ -1556,19 +1796,27 @@ export const sessionsHandlers: GatewayRequestHandlers = { ); emitSessionsChanged(context, { sessionKey: target.canonicalKey, + ...(target.canonicalKey === "global" ? { agentId: target.agentId } : {}), reason: "create", }); if (runStarted) { emitSessionsChanged(context, { sessionKey: target.canonicalKey, + ...(target.canonicalKey === "global" ? { agentId: target.agentId } : {}), reason: "send", }); } if (canonicalParentSessionKey && p.emitCommandHooks === true) { - const { entry: parentEntry } = loadSessionEntry(canonicalParentSessionKey); + const { entry: parentEntry } = loadSessionEntry( + canonicalParentSessionKey, + parentSelectedAgentId ? { agentId: parentSelectedAgentId } : undefined, + ); const parentTarget = resolveGatewaySessionStoreTarget({ cfg, key: canonicalParentSessionKey, + ...(canonicalParentSessionKey === "global" && parentSelectedAgentId + ? { agentId: parentSelectedAgentId } + : {}), }); const { emitGatewaySessionEndPluginHook, emitGatewaySessionStartPluginHook } = await loadSessionsRuntimeModule(); @@ -1616,9 +1864,19 @@ export const sessionsHandlers: GatewayRequestHandlers = { respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "checkpointId required")); return; } - const loaded = loadSessionEntry(key); - const { cfg, entry, canonicalKey } = loaded; - const target = resolveGatewaySessionStoreTarget({ cfg, key: canonicalKey }); + const cfg = context.getRuntimeConfig(); + const requestedAgent = resolveRequestedGlobalAgentId(cfg, key, p.agentId); + if (!requestedAgent.ok) { + respond(false, undefined, requestedAgent.error); + return; + } + const loaded = loadSessionEntry(key, { agentId: requestedAgent.agentId }); + const { cfg: loadedCfg, entry, canonicalKey } = loaded; + const target = resolveGatewaySessionStoreTarget({ + cfg: loadedCfg, + key: canonicalKey, + agentId: requestedAgent.agentId, + }); if (!entry?.sessionId) { respond( false, @@ -1679,6 +1937,9 @@ export const sessionsHandlers: GatewayRequestHandlers = { ); emitSessionsChanged(context, { sessionKey: canonicalKey, + ...(canonicalKey === "global" && requestedAgent.agentId + ? { agentId: requestedAgent.agentId } + : {}), reason: "checkpoint-branch", }); emitSessionsChanged(context, { @@ -1718,7 +1979,13 @@ export const sessionsHandlers: GatewayRequestHandlers = { respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "checkpointId required")); return; } - const loaded = loadSessionEntry(key); + const cfg = context.getRuntimeConfig(); + const requestedAgent = resolveRequestedGlobalAgentId(cfg, key, p.agentId); + if (!requestedAgent.ok) { + respond(false, undefined, requestedAgent.error); + return; + } + const loaded = loadSessionEntry(key, { agentId: requestedAgent.agentId }); const { entry, canonicalKey, storePath } = loaded; if (!entry?.sessionId) { respond( @@ -1745,6 +2012,7 @@ export const sessionsHandlers: GatewayRequestHandlers = { isWebchatConnect, requestedKey: key, canonicalKey, + agentId: requestedAgent.agentId, sessionId: entry.sessionId, }); if (interruptResult.error) { @@ -1790,6 +2058,9 @@ export const sessionsHandlers: GatewayRequestHandlers = { ); emitSessionsChanged(context, { sessionKey: canonicalKey, + ...(canonicalKey === "global" && requestedAgent.agentId + ? { agentId: requestedAgent.agentId } + : {}), reason: "checkpoint-restore", }); }, @@ -1842,11 +2113,14 @@ export const sessionsHandlers: GatewayRequestHandlers = { const requestedKeyAgentId = scopedRequestedKey ? resolveSessionKeyAgentId(scopedRequestedKey, cfg) : undefined; - const activeRunSessionKey = requestedRunId - ? context.chatAbortControllers.get(requestedRunId)?.sessionKey - : undefined; + const activeRun = requestedRunId ? context.chatAbortControllers.get(requestedRunId) : undefined; + const activeRunSessionKey = activeRun?.sessionKey; + const activeRunAgentId = normalizeOptionalString(activeRun?.agentId); const inferredRunAgentId = requestedParamAgentId ?? + (requestedRunId && scopedRequestedKey?.toLowerCase() === "global" + ? activeRunAgentId + : undefined) ?? requestedKeyAgentId ?? (requestedRunId && !activeRunSessionKey ? resolveDefaultAgentId(cfg) : undefined); const requestedRunAgentId = requestedRunId @@ -1877,20 +2151,34 @@ export const sessionsHandlers: GatewayRequestHandlers = { if (!key) { return; } - const { canonicalKey } = loadSessionEntry(key); + const requestedGlobalAgent = resolveRequestedGlobalAgentId( + cfg, + key, + requestedParamAgentId ?? requestedRunAgentId, + ); + if (!requestedGlobalAgent.ok) { + respond(false, undefined, requestedGlobalAgent.error); + return; + } + const requestedGlobalAgentId = requestedGlobalAgent.agentId; + const { canonicalKey } = loadSessionEntry(key, { agentId: requestedGlobalAgentId }); const requestedKeyAliases = requestedKey && requestedKey !== key && (!requestedParamAgentId || sessionKeyBelongsToAgent(requestedKey, requestedParamAgentId, cfg)) ? [requestedKey] : undefined; - const abortSessionKey = resolveAbortSessionKey({ + const resolvedAbortSessionKey = resolveAbortSessionKey({ context, requestedKey: key, canonicalKey, activeRunSessionKey: scopedActiveRunSessionKey, aliasKeys: requestedKeyAliases, }); + const abortSessionKey = + canonicalKey === "global" && requestedGlobalAgentId ? "global" : resolvedAbortSessionKey; + const abortAgentId = + abortSessionKey === "global" ? (requestedGlobalAgentId ?? activeRunAgentId) : undefined; // Capture run kinds before the abort because abortChatRunById deletes entries // from chatAbortControllers synchronously. We use this snapshot to choose the // correct dedupe namespace: agent-kind runs use "agent:" (their runId equals @@ -1910,6 +2198,7 @@ export const sessionsHandlers: GatewayRequestHandlers = { params: { sessionKey: abortSessionKey, runId: requestedRunId, + ...(abortAgentId ? { agentId: abortAgentId } : {}), }, respond: (ok, payload, error, meta) => { if (!ok) { @@ -1939,6 +2228,7 @@ export const sessionsHandlers: GatewayRequestHandlers = { payload: { status: "timeout", runId: firstAbortedRunId, + ...(abortAgentId ? { agentId: abortAgentId } : {}), stopReason: "rpc", endedAt, }, @@ -1963,6 +2253,7 @@ export const sessionsHandlers: GatewayRequestHandlers = { if (abortedRunId) { emitSessionsChanged(context, { sessionKey: canonicalKey, + ...(canonicalKey === "global" && abortAgentId ? { agentId: abortAgentId } : {}), reason: "abort", }); } @@ -1980,16 +2271,28 @@ export const sessionsHandlers: GatewayRequestHandlers = { return; } - const { cfg, target, storePath } = resolveGatewaySessionTargetFromKey( - key, - context.getRuntimeConfig(), - ); + const cfg = context.getRuntimeConfig(); + const requestedAgent = resolveRequestedGlobalAgentId(cfg, key, p.agentId); + if (!requestedAgent.ok) { + respond(false, undefined, requestedAgent.error); + return; + } + const requestedAgentId = requestedAgent.agentId; + const { target, storePath } = resolveGatewaySessionTargetFromKey(key, cfg, { + agentId: requestedAgentId, + }); const applied = await updateSessionStore(storePath, async (store) => { - const { primaryKey } = migrateAndPruneGatewaySessionStoreKey({ cfg, key, store }); + const { primaryKey } = migrateAndPruneGatewaySessionStoreKey({ + cfg, + key, + store, + agentId: requestedAgentId, + }); return await applySessionsPatchToStore({ cfg, store, storeKey: primaryKey, + agentId: requestedAgentId, patch: p, loadGatewayModelCatalog: context.loadGatewayModelCatalog, }); @@ -2007,7 +2310,11 @@ export const sessionsHandlers: GatewayRequestHandlers = { }); const parsed = parseAgentSessionKey(target.canonicalKey ?? key); - const agentId = normalizeAgentId(parsed?.agentId ?? resolveDefaultAgentId(cfg)); + const agentId = normalizeAgentId( + target.canonicalKey === "global" + ? target.agentId + : (parsed?.agentId ?? resolveDefaultAgentId(cfg)), + ); const resolved = resolveSessionModelRef(cfg, applied.entry, agentId); const resolvedDisplayModel = resolveSessionDisplayModelIdentityRef({ cfg, @@ -2038,6 +2345,9 @@ export const sessionsHandlers: GatewayRequestHandlers = { respond(true, result, undefined); emitSessionsChanged(context, { sessionKey: target.canonicalKey, + ...(target.canonicalKey === "global" && requestedAgentId + ? { agentId: requestedAgentId } + : {}), reason: "patch", }); }, @@ -2130,6 +2440,7 @@ export const sessionsHandlers: GatewayRequestHandlers = { const { performGatewaySessionReset } = await loadSessionsRuntimeModule(); const result = await performGatewaySessionReset({ key, + ...(p.agentId ? { agentId: p.agentId } : {}), reason, commandSource: "gateway:sessions.reset", }); @@ -2140,6 +2451,7 @@ export const sessionsHandlers: GatewayRequestHandlers = { respond(true, { ok: true, key: result.key, entry: result.entry }, undefined); emitSessionsChanged(context, { sessionKey: result.key, + ...(result.key === "global" ? { agentId: result.agentId } : {}), reason, }); }, @@ -2156,12 +2468,22 @@ export const sessionsHandlers: GatewayRequestHandlers = { return; } - const { cfg, target, storePath } = resolveGatewaySessionTargetFromKey( - key, - context.getRuntimeConfig(), - ); + const cfg = context.getRuntimeConfig(); + const requestedAgent = resolveRequestedGlobalAgentId(cfg, key, p.agentId); + if (!requestedAgent.ok) { + respond(false, undefined, requestedAgent.error); + return; + } + const requestedAgentId = requestedAgent.agentId; + const { target, storePath } = resolveGatewaySessionTargetFromKey(key, cfg, { + agentId: requestedAgentId, + }); const mainKey = resolveMainSessionKey(cfg); - if (target.canonicalKey === mainKey) { + const isSelectedNonDefaultGlobal = + target.canonicalKey === "global" && + requestedAgentId !== undefined && + requestedAgentId !== resolveDefaultAgentId(cfg); + if (target.canonicalKey === mainKey && !isSelectedNonDefaultGlobal) { respond( false, undefined, @@ -2178,7 +2500,9 @@ export const sessionsHandlers: GatewayRequestHandlers = { emitSessionUnboundLifecycleEvent, } = await loadSessionsRuntimeModule(); - const { entry, legacyKey, canonicalKey } = loadSessionEntry(key); + const { entry, legacyKey, canonicalKey } = loadSessionEntry(key, { + agentId: requestedAgentId, + }); if (rejectPluginRuntimeDeleteMismatch({ client, key: canonicalKey ?? key, entry, respond })) { return; } @@ -2197,7 +2521,12 @@ export const sessionsHandlers: GatewayRequestHandlers = { } const sessionId = entry?.sessionId; const deleted = await updateSessionStore(storePath, (store) => { - const { primaryKey } = migrateAndPruneGatewaySessionStoreKey({ cfg, key, store }); + const { primaryKey } = migrateAndPruneGatewaySessionStoreKey({ + cfg, + key, + store, + agentId: requestedAgentId, + }); const hadEntry = Boolean(store[primaryKey]); if (hadEntry) { delete store[primaryKey]; @@ -2239,12 +2568,20 @@ export const sessionsHandlers: GatewayRequestHandlers = { if (deleted) { emitSessionsChanged(context, { sessionKey: target.canonicalKey, + ...(target.canonicalKey === "global" && requestedAgentId + ? { agentId: requestedAgentId } + : {}), reason: "delete", }); } }, "sessions.get": async ({ params, respond, context }) => { - const p = params; + const p = params as { + key?: unknown; + sessionKey?: unknown; + limit?: unknown; + agentId?: unknown; + }; const key = requireSessionKey(p.key ?? p.sessionKey, respond); if (!key) { return; @@ -2254,10 +2591,19 @@ export const sessionsHandlers: GatewayRequestHandlers = { ? Math.max(1, Math.floor(p.limit)) : 200; - const { target, storePath } = resolveGatewaySessionTargetFromKey( + const cfg = context.getRuntimeConfig(); + const requestedAgent = resolveRequestedGlobalAgentId( + cfg, key, - context.getRuntimeConfig(), + normalizeOptionalString(p.agentId), ); + if (!requestedAgent.ok) { + respond(false, undefined, requestedAgent.error); + return; + } + const { target, storePath } = resolveGatewaySessionTargetFromKey(key, cfg, { + agentId: requestedAgent.agentId, + }); const store = loadSessionStore(storePath); const entry = resolveFreshestSessionEntryFromStoreKeys(store, target.storeKeys); if (!entry?.sessionId) { @@ -2293,13 +2639,24 @@ export const sessionsHandlers: GatewayRequestHandlers = { ? Math.max(1, Math.floor(p.maxLines)) : undefined; - const { cfg, target, storePath } = resolveGatewaySessionTargetFromKey( - key, - context.getRuntimeConfig(), - ); + const cfg = context.getRuntimeConfig(); + const requestedAgent = resolveRequestedGlobalAgentId(cfg, key, p.agentId); + if (!requestedAgent.ok) { + respond(false, undefined, requestedAgent.error); + return; + } + const requestedAgentId = requestedAgent.agentId; + const { target, storePath } = resolveGatewaySessionTargetFromKey(key, cfg, { + agentId: requestedAgentId, + }); // Lock + read in a short critical section; transcript work happens outside. const compactTarget = await updateSessionStore(storePath, (store) => { - const { entry, primaryKey } = migrateAndPruneGatewaySessionStoreKey({ cfg, key, store }); + const { entry, primaryKey } = migrateAndPruneGatewaySessionStoreKey({ + cfg, + key, + store, + agentId: requestedAgentId, + }); return { entry, primaryKey }; }); const entry = compactTarget.entry; @@ -2346,6 +2703,7 @@ export const sessionsHandlers: GatewayRequestHandlers = { isWebchatConnect, requestedKey: key, canonicalKey: target.canonicalKey, + agentId: requestedAgentId, sessionId, }); if (interruptResult.error) { @@ -2364,12 +2722,14 @@ export const sessionsHandlers: GatewayRequestHandlers = { operation: "compact", phase: "start", sessionKey: target.canonicalKey, + ...(target.canonicalKey === "global" && target.agentId ? { agentId: target.agentId } : {}), }); let result: Awaited>; try { result = await compactEmbeddedAgentSession({ sessionId, sessionKey: target.canonicalKey, + agentId: target.agentId, allowGatewaySubagentBinding: true, sessionFile: filePath, workspaceDir, @@ -2393,6 +2753,9 @@ export const sessionsHandlers: GatewayRequestHandlers = { operation: "compact", phase: "end", sessionKey: target.canonicalKey, + ...(target.canonicalKey === "global" && target.agentId + ? { agentId: target.agentId } + : {}), completed: false, reason: formatErrorMessage(err), }); @@ -2403,6 +2766,7 @@ export const sessionsHandlers: GatewayRequestHandlers = { operation: "compact", phase: "end", sessionKey: target.canonicalKey, + ...(target.canonicalKey === "global" && target.agentId ? { agentId: target.agentId } : {}), completed: result.ok && result.compacted, reason: result.reason, }); @@ -2452,6 +2816,9 @@ export const sessionsHandlers: GatewayRequestHandlers = { if (result.ok) { emitSessionsChanged(context, { sessionKey: target.canonicalKey, + ...(target.canonicalKey === "global" && target.agentId + ? { agentId: target.agentId } + : {}), reason: "compact", compacted: result.compacted, }); @@ -2512,6 +2879,7 @@ export const sessionsHandlers: GatewayRequestHandlers = { ); emitSessionsChanged(context, { sessionKey: target.canonicalKey, + ...(target.canonicalKey === "global" && target.agentId ? { agentId: target.agentId } : {}), reason: "compact", compacted: true, }); diff --git a/src/gateway/server-methods/shared-types.ts b/src/gateway/server-methods/shared-types.ts index 47836f6b1ffd..cd69aa0117bc 100644 --- a/src/gateway/server-methods/shared-types.ts +++ b/src/gateway/server-methods/shared-types.ts @@ -95,12 +95,15 @@ export type GatewayRequestContext = { agentDeltaSentAt: Map; bufferedAgentEvents: Map; clearChatRunState: (runId: string) => void; - addChatRun: (sessionId: string, entry: { sessionKey: string; clientRunId: string }) => void; + addChatRun: ( + sessionId: string, + entry: { sessionKey: string; agentId?: string; clientRunId: string }, + ) => void; removeChatRun: ( sessionId: string, clientRunId: string, sessionKey?: string, - ) => { sessionKey: string; clientRunId: string } | undefined; + ) => { sessionKey: string; agentId?: string; clientRunId: string } | undefined; subscribeSessionEvents: (connId: string) => void; unsubscribeSessionEvents: (connId: string) => void; subscribeSessionMessageEvents: (connId: string, sessionKey: string) => void; diff --git a/src/gateway/server-session-events.ts b/src/gateway/server-session-events.ts index fd767530bea9..e260f47497a4 100644 --- a/src/gateway/server-session-events.ts +++ b/src/gateway/server-session-events.ts @@ -1,6 +1,10 @@ +import { resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { getRuntimeConfig } from "../config/io.js"; +import { normalizeAgentId } from "../routing/session-key.js"; import type { SessionLifecycleEvent } from "../sessions/session-lifecycle-events.js"; import type { SessionTranscriptUpdate } from "../sessions/transcript-events.js"; import { asPositiveSafeInteger } from "../shared/number-coercion.js"; +import { normalizeOptionalString } from "../shared/string-coerce.js"; import { projectChatDisplayMessage } from "./chat-display-projection.js"; import type { GatewayBroadcastToConnIdsFn } from "./server-broadcast-types.js"; import type { @@ -19,8 +23,24 @@ import { type SessionEventSubscribers = Pick; type SessionMessageSubscribers = Pick; +function resolveSessionMessageBroadcastKeys(sessionKey: string, agentId?: string): string[] { + const normalizedAgentId = normalizeOptionalString(agentId); + if (sessionKey === "global") { + const defaultAgentId = normalizeAgentId(resolveDefaultAgentId(getRuntimeConfig())); + if (normalizedAgentId) { + const scopedKey = `agent:${normalizeAgentId(normalizedAgentId)}:global`; + return normalizeAgentId(normalizedAgentId) === defaultAgentId + ? [scopedKey, sessionKey] + : [scopedKey]; + } + return [`agent:${defaultAgentId}:global`, sessionKey]; + } + return [sessionKey]; +} + function buildGatewaySessionSnapshot(params: { sessionRow: GatewaySessionRow | null | undefined; + agentId?: string; includeSession?: boolean; label?: string; displayName?: string; @@ -30,8 +50,13 @@ function buildGatewaySessionSnapshot(params: { if (!sessionRow) { return {}; } + const omitUnscopedGlobalGoal = sessionRow.key === "global" && !params.agentId; + const session = params.includeSession ? { ...sessionRow } : undefined; + if (session && omitUnscopedGlobalGoal) { + delete session.goal; + } return { - ...(params.includeSession ? { session: sessionRow } : {}), + ...(session ? { session } : {}), updatedAt: sessionRow.updatedAt ?? undefined, sessionId: sessionRow.sessionId, kind: sessionRow.kind, @@ -69,6 +94,7 @@ function buildGatewaySessionSnapshot(params: { lastThreadId: sessionRow.lastThreadId, totalTokens: sessionRow.totalTokens, totalTokensFresh: sessionRow.totalTokensFresh, + ...(omitUnscopedGlobalGoal ? {} : { goal: sessionRow.goal ?? null }), contextTokens: sessionRow.contextTokens, estimatedCostUsd: sessionRow.estimatedCostUsd, responseUsage: sessionRow.responseUsage, @@ -110,19 +136,29 @@ async function handleTranscriptUpdateBroadcast( if (!sessionKey || update.message === undefined) { return; } + const effectiveAgentId = update.agentId; + const defaultGlobalAgentId = + sessionKey === "global" + ? normalizeAgentId(resolveDefaultAgentId(getRuntimeConfig())) + : undefined; + const visibleAgentId = + update.agentId ?? + (effectiveAgentId && effectiveAgentId !== defaultGlobalAgentId ? effectiveAgentId : undefined); const connIds = new Set(); for (const connId of params.sessionEventSubscribers.getAll()) { connIds.add(connId); } - for (const connId of params.sessionMessageSubscribers.get(sessionKey)) { - connIds.add(connId); + for (const broadcastKey of resolveSessionMessageBroadcastKeys(sessionKey, effectiveAgentId)) { + for (const connId of params.sessionMessageSubscribers.get(broadcastKey)) { + connIds.add(connId); + } } if (connIds.size === 0) { return; } let messageSeq = asPositiveSafeInteger(update.messageSeq); if (messageSeq === undefined) { - const { entry, storePath } = loadSessionEntry(sessionKey); + const { entry, storePath } = loadSessionEntry(sessionKey, { agentId: visibleAgentId }); messageSeq = entry?.sessionId ? asPositiveSafeInteger( await readSessionMessageCountAsync(entry.sessionId, storePath, entry.sessionFile), @@ -130,7 +166,11 @@ async function handleTranscriptUpdateBroadcast( : undefined; } const sessionSnapshot = buildGatewaySessionSnapshot({ - sessionRow: loadGatewaySessionRow(sessionKey, { transcriptUsageMaxBytes: 64 * 1024 }), + sessionRow: loadGatewaySessionRow(sessionKey, { + agentId: visibleAgentId, + transcriptUsageMaxBytes: 64 * 1024, + }), + agentId: visibleAgentId, includeSession: true, }); const rawMessage = attachOpenClawTranscriptMeta(update.message, { @@ -143,6 +183,7 @@ async function handleTranscriptUpdateBroadcast( "session.message", { sessionKey, + ...(visibleAgentId ? { agentId: visibleAgentId } : {}), message, ...(typeof update.messageId === "string" ? { messageId: update.messageId } : {}), ...(messageSeq !== undefined ? { messageSeq } : {}), @@ -162,6 +203,7 @@ async function handleTranscriptUpdateBroadcast( "sessions.changed", { sessionKey, + ...(visibleAgentId ? { agentId: visibleAgentId } : {}), phase: "message", ts: Date.now(), ...(typeof update.messageId === "string" ? { messageId: update.messageId } : {}), diff --git a/src/gateway/server.sessions.compaction.test.ts b/src/gateway/server.sessions.compaction.test.ts index 49e4bd349993..6852170f523d 100644 --- a/src/gateway/server.sessions.compaction.test.ts +++ b/src/gateway/server.sessions.compaction.test.ts @@ -9,6 +9,7 @@ import { agentDiscoveryMock, rpcReq, startConnectedServerWithClient, + testState, writeSessionStore, } from "./test-helpers.js"; import { @@ -17,6 +18,7 @@ import { getGatewayConfigModule, sessionStoreEntry, createCheckpointFixture, + directSessionReq, } from "./test/server-sessions.test-helpers.js"; const { createSessionStoreDir, openClient } = setupGatewaySessionsTestHarness(); @@ -274,6 +276,102 @@ test("sessions.compaction.* lists checkpoints and branches or restores from comp ws.close(); }); +test("sessions.compaction.* scopes selected global checkpoints to the requested agent", async () => { + const { dir } = await createSessionStoreDir(); + const storeTemplate = path.join(dir, "{agentId}", "sessions.json"); + testState.sessionStorePath = storeTemplate; + testState.sessionConfig = { scope: "global" }; + testState.agentsConfig = { list: [{ id: "main", default: true }, { id: "work" }] }; + const mainStorePath = storeTemplate.replace("{agentId}", "main"); + const workStorePath = storeTemplate.replace("{agentId}", "work"); + const workDir = path.dirname(workStorePath); + await fs.mkdir(path.dirname(mainStorePath), { recursive: true }); + await fs.mkdir(workDir, { recursive: true }); + const mainSessionFile = path.join(path.dirname(mainStorePath), "sess-main-global.jsonl"); + await fs.writeFile(mainSessionFile, `${JSON.stringify({ role: "user", content: "main" })}\n`); + const fixture = await createCheckpointFixture(workDir, { legacyPreCompactionSnapshot: false }); + const checkpointCreatedAt = Date.now(); + await fs.writeFile( + mainStorePath, + JSON.stringify( + { global: sessionStoreEntry("sess-main-global", { sessionFile: mainSessionFile }) }, + null, + 2, + ), + ); + await fs.writeFile( + workStorePath, + JSON.stringify( + { + global: sessionStoreEntry(fixture.sessionId, { + sessionFile: fixture.sessionFile, + compactionCheckpoints: [ + { + checkpointId: "checkpoint-work", + sessionKey: "global", + sessionId: fixture.sessionId, + createdAt: checkpointCreatedAt, + reason: "manual", + summary: "work checkpoint", + firstKeptEntryId: fixture.preCompactionLeafId, + preCompaction: { + sessionId: fixture.sessionId, + leafId: fixture.preCompactionLeafId, + }, + postCompaction: { + sessionId: fixture.sessionId, + sessionFile: fixture.sessionFile, + leafId: fixture.postCompactionLeafId, + entryId: fixture.postCompactionLeafId, + }, + }, + ], + }), + }, + null, + 2, + ), + ); + + const listed = await directSessionReq<{ + checkpoints: Array<{ checkpointId: string; summary?: string }>; + }>("sessions.compaction.list", { key: "global", agentId: "work" }); + expect(listed.ok).toBe(true); + expect(listed.payload?.checkpoints).toHaveLength(1); + expect(listed.payload?.checkpoints[0]).toMatchObject({ + checkpointId: "checkpoint-work", + summary: "work checkpoint", + }); + + const branched = await directSessionReq<{ key?: string; sourceKey?: string }>( + "sessions.compaction.branch", + { key: "global", agentId: "work", checkpointId: "checkpoint-work" }, + ); + expect(branched.ok).toBe(true); + expect(branched.payload?.sourceKey).toBe("global"); + expect(branched.payload?.key).toMatch(/^agent:work:dashboard:/); + + const restored = await directSessionReq<{ key?: string; sessionId?: string }>( + "sessions.compaction.restore", + { key: "global", agentId: "work", checkpointId: "checkpoint-work" }, + ); + expect(restored.ok).toBe(true); + expect(restored.payload?.key).toBe("global"); + const mainStore = JSON.parse(await fs.readFile(mainStorePath, "utf-8")) as Record< + string, + { sessionId?: string } + >; + const workStore = JSON.parse(await fs.readFile(workStorePath, "utf-8")) as Record< + string, + { sessionId?: string } + >; + expect(mainStore.global?.sessionId).toBe("sess-main-global"); + expect(workStore.global?.sessionId).toBe(restored.payload?.sessionId); + testState.sessionStorePath = undefined; + testState.sessionConfig = undefined; + testState.agentsConfig = undefined; +}); + test("sessions.compact without maxLines runs embedded manual compaction for checkpoint-capable flows", async () => { const { dir, storePath } = await createSessionStoreDir(); await fs.writeFile( diff --git a/src/gateway/server.sessions.create.test.ts b/src/gateway/server.sessions.create.test.ts index 477e614efd97..5a90854217e4 100644 --- a/src/gateway/server.sessions.create.test.ts +++ b/src/gateway/server.sessions.create.test.ts @@ -1,11 +1,13 @@ import fs from "node:fs/promises"; import path from "node:path"; -import { expect, test } from "vitest"; +import { expect, test, vi } from "vitest"; import { agentDiscoveryMock, rpcReq, testState, writeSessionStore } from "./test-helpers.js"; import { setupGatewaySessionsTestHarness, sessionStoreEntry, directSessionReq, + sessionHookMocks, + sessionLifecycleHookMocks, } from "./test/server-sessions.test-helpers.js"; const { createSessionStoreDir, openClient } = setupGatewaySessionsTestHarness(); @@ -299,6 +301,233 @@ test("sessions.create preserves global and unknown sentinel keys", async () => { expect(rawStore["agent:longmemeval:unknown"]).toBeUndefined(); }); +test("sessions.create stores selected global sessions in the requested agent store", async () => { + const { dir } = await createSessionStoreDir(); + const storeTemplate = path.join(dir, "{agentId}", "sessions.json"); + const mainStorePath = storeTemplate.replace("{agentId}", "main"); + const workStorePath = storeTemplate.replace("{agentId}", "work"); + testState.sessionStorePath = storeTemplate; + testState.sessionConfig = { scope: "global" }; + testState.agentsConfig = { list: [{ id: "main", default: true }, { id: "work" }] }; + const broadcastToConnIds = vi.fn(); + + const created = await directSessionReq<{ + key?: string; + sessionId?: string; + entry?: { sessionFile?: string }; + }>( + "sessions.create", + { + key: "global", + agentId: "work", + }, + { + context: { + broadcastToConnIds, + getSessionEventSubscriberConnIds: () => new Set(["conn-1"]), + }, + }, + ); + + expect(created.ok).toBe(true); + expect(created.payload?.key).toBe("global"); + requireNonEmptyString(created.payload?.entry?.sessionFile, "work global session file"); + await expect(fs.readFile(mainStorePath, "utf-8")).rejects.toMatchObject({ code: "ENOENT" }); + const workStore = JSON.parse(await fs.readFile(workStorePath, "utf-8")) as Record< + string, + { sessionId?: string } + >; + expect(workStore.global?.sessionId).toBe(created.payload?.sessionId); + expect(broadcastToConnIds).toHaveBeenCalledWith( + "sessions.changed", + expect.objectContaining({ sessionKey: "global", agentId: "work", reason: "create" }), + new Set(["conn-1"]), + { dropIfSlow: true }, + ); + testState.sessionStorePath = undefined; + testState.sessionConfig = undefined; + testState.agentsConfig = undefined; +}); + +test("sessions.create loads selected global parent from the requested agent store", async () => { + const { dir } = await createSessionStoreDir(); + const storeTemplate = path.join(dir, "{agentId}", "sessions.json"); + const mainStorePath = storeTemplate.replace("{agentId}", "main"); + const workStorePath = storeTemplate.replace("{agentId}", "work"); + testState.sessionStorePath = storeTemplate; + testState.sessionConfig = { scope: "global" }; + testState.agentsConfig = { list: [{ id: "main", default: true }, { id: "work" }] }; + try { + await writeSessionStore({ + storePath: mainStorePath, + entries: { + global: sessionStoreEntry("sess-main-parent", { + providerOverride: "codex", + modelOverride: "main-model", + }), + }, + }); + await writeSessionStore({ + storePath: workStorePath, + agentId: "work", + entries: { + global: sessionStoreEntry("sess-work-parent", { + providerOverride: "openai", + modelOverride: "work-model", + thinkingLevel: "high", + }), + }, + }); + + const created = await directSessionReq<{ + key?: string; + entry?: { + parentSessionKey?: string; + providerOverride?: string; + modelOverride?: string; + thinkingLevel?: string; + }; + }>("sessions.create", { + agentId: "work", + parentSessionKey: "global", + emitCommandHooks: true, + }); + + expect(created.ok).toBe(true); + expect(created.payload?.key).toMatch(/^agent:work:dashboard:/); + expect(created.payload?.entry?.parentSessionKey).toBe("global"); + expect(created.payload?.entry?.providerOverride).toBe("openai"); + expect(created.payload?.entry?.modelOverride).toBe("work-model"); + expect(created.payload?.entry?.thinkingLevel).toBe("high"); + + const commandNewEvent = ( + sessionHookMocks.triggerInternalHook.mock.calls as unknown as Array<[unknown]> + ) + .map((call) => call[0]) + .find( + ( + event, + ): event is { + context?: { sessionEntry?: { sessionId?: string } }; + } => + Boolean(event) && + typeof event === "object" && + (event as { type?: unknown }).type === "command" && + (event as { action?: unknown }).action === "new", + ); + expect(commandNewEvent?.context?.sessionEntry?.sessionId).toBe("sess-work-parent"); + const [endEvent] = sessionLifecycleHookMocks.runSessionEnd.mock.calls[0] as unknown as [ + { sessionId?: string; sessionKey?: string }, + unknown, + ]; + expect(endEvent.sessionId).toBe("sess-work-parent"); + expect(endEvent.sessionKey).toBe("global"); + } finally { + testState.sessionStorePath = undefined; + testState.sessionConfig = undefined; + testState.agentsConfig = undefined; + } +}); + +test("sessions.get reads selected global messages from the requested agent store", async () => { + const { dir } = await createSessionStoreDir(); + const storeTemplate = path.join(dir, "{agentId}", "sessions.json"); + const mainStorePath = storeTemplate.replace("{agentId}", "main"); + const workStorePath = storeTemplate.replace("{agentId}", "work"); + const mainTranscriptPath = path.join(path.dirname(mainStorePath), "sess-main-global.jsonl"); + const workTranscriptPath = path.join(path.dirname(workStorePath), "sess-work-global.jsonl"); + await fs.mkdir(path.dirname(mainTranscriptPath), { recursive: true }); + await fs.mkdir(path.dirname(workTranscriptPath), { recursive: true }); + await fs.writeFile( + mainTranscriptPath, + `${JSON.stringify({ type: "message", id: "main-msg", message: { role: "user", content: "main global" } })}\n`, + "utf-8", + ); + await fs.writeFile( + workTranscriptPath, + `${JSON.stringify({ type: "message", id: "work-msg", message: { role: "user", content: "work global" } })}\n`, + "utf-8", + ); + testState.sessionStorePath = storeTemplate; + testState.sessionConfig = { scope: "global" }; + testState.agentsConfig = { list: [{ id: "main", default: true }, { id: "work" }] }; + try { + await writeSessionStore({ + storePath: mainStorePath, + entries: { + global: sessionStoreEntry("sess-main-global", { + sessionFile: mainTranscriptPath, + }), + }, + }); + await writeSessionStore({ + storePath: workStorePath, + agentId: "work", + entries: { + global: sessionStoreEntry("sess-work-global", { + sessionFile: workTranscriptPath, + }), + }, + }); + + const result = await directSessionReq<{ messages?: unknown[] }>("sessions.get", { + key: "global", + agentId: "work", + }); + + expect(result.ok).toBe(true); + const renderedMessages = JSON.stringify(result.payload?.messages ?? []); + expect(renderedMessages).toContain("work global"); + expect(renderedMessages).not.toContain("main global"); + } finally { + testState.sessionStorePath = undefined; + testState.sessionConfig = undefined; + testState.agentsConfig = undefined; + } +}); + +test("sessions.create sends selected global initial tasks to the requested agent", async () => { + const { dir } = await createSessionStoreDir(); + const storeTemplate = path.join(dir, "{agentId}", "sessions.json"); + testState.sessionStorePath = storeTemplate; + testState.sessionConfig = { scope: "global" }; + testState.agentsConfig = { list: [{ id: "main", default: true }, { id: "work" }] }; + const { ws } = await openClient(); + + const created = await rpcReq<{ + key?: string; + runStarted?: boolean; + runId?: string; + }>(ws, "sessions.create", { + key: "global", + agentId: "work", + task: "hello selected global", + }); + + expect(created.ok).toBe(true); + expect(created.payload?.key).toBe("global"); + expect(created.payload?.runStarted).toBe(true); + const runId = requireNonEmptyString(created.payload?.runId, "selected global run id"); + const wait = await rpcReq(ws, "agent.wait", { runId, timeoutMs: 1_000 }); + expect(wait.ok).toBe(true); + const workStorePath = storeTemplate.replace("{agentId}", "work"); + const mainStorePath = storeTemplate.replace("{agentId}", "main"); + const workStore = JSON.parse(await fs.readFile(workStorePath, "utf-8")) as Record< + string, + { sessionFile?: string } + >; + const workTranscript = requireNonEmptyString( + workStore.global?.sessionFile, + "selected global transcript", + ); + await expect(fs.readFile(workTranscript, "utf-8")).resolves.toContain("hello selected global"); + await expect(fs.readFile(mainStorePath, "utf-8")).rejects.toMatchObject({ code: "ENOENT" }); + testState.sessionStorePath = undefined; + testState.sessionConfig = undefined; + testState.agentsConfig = undefined; + ws.close(); +}); + test("sessions.create rejects unknown parentSessionKey", async () => { await createSessionStoreDir(); diff --git a/src/gateway/server.sessions.delete-lifecycle.test.ts b/src/gateway/server.sessions.delete-lifecycle.test.ts index 090e7d70497a..8355b05fc5d3 100644 --- a/src/gateway/server.sessions.delete-lifecycle.test.ts +++ b/src/gateway/server.sessions.delete-lifecycle.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { expect, test } from "vitest"; -import { embeddedRunMock, rpcReq, writeSessionStore } from "./test-helpers.js"; +import { embeddedRunMock, rpcReq, testState, writeSessionStore } from "./test-helpers.js"; import { setupGatewaySessionsTestHarness, sessionLifecycleHookMocks, @@ -135,6 +135,72 @@ test("sessions.delete limits plugin-runtime cleanup to sessions owned by that pl expect(deleted.payload?.deleted).toBe(true); }); +test("sessions.delete scopes selected global deletes to the requested agent", async () => { + const { dir } = await createSessionStoreDir(); + const storeTemplate = path.join(dir, "{agentId}", "sessions.json"); + testState.sessionStorePath = storeTemplate; + testState.sessionConfig = { scope: "global" }; + await writeSessionStore({ + entries: {}, + storePath: path.join(dir, "prime-sessions.json"), + }); + const mainStorePath = storeTemplate.replace("{agentId}", "main"); + const workStorePath = storeTemplate.replace("{agentId}", "work"); + await fs.mkdir(path.dirname(mainStorePath), { recursive: true }); + await fs.mkdir(path.dirname(workStorePath), { recursive: true }); + await fs.writeFile( + mainStorePath, + JSON.stringify({ global: sessionStoreEntry("sess-main-global") }, null, 2), + "utf-8", + ); + await fs.writeFile( + workStorePath, + JSON.stringify({ global: sessionStoreEntry("sess-work-global") }, null, 2), + "utf-8", + ); + const configPath = process.env.OPENCLAW_CONFIG_PATH; + if (!configPath) { + throw new Error("OPENCLAW_CONFIG_PATH is required"); + } + await fs.writeFile( + configPath, + `${JSON.stringify( + { + agents: { list: [{ id: "main", default: true }, { id: "work" }] }, + session: { scope: "global", store: storeTemplate }, + }, + null, + 2, + )}\n`, + "utf-8", + ); + const { clearConfigCache, clearRuntimeConfigSnapshot } = await import("../config/config.js"); + clearRuntimeConfigSnapshot(); + clearConfigCache(); + + const deleted = await directSessionReq<{ ok: true; deleted: boolean }>("sessions.delete", { + key: "global", + agentId: "work", + deleteTranscript: false, + }); + + expect(deleted.ok).toBe(true); + expect(deleted.payload?.deleted).toBe(true); + const mainStore = JSON.parse(await fs.readFile(mainStorePath, "utf-8")) as { + global?: { sessionId?: string }; + }; + const workStore = JSON.parse(await fs.readFile(workStorePath, "utf-8")) as { + global?: { sessionId?: string }; + }; + expect(mainStore.global?.sessionId).toBe("sess-main-global"); + expect(workStore.global).toBeUndefined(); + testState.sessionStorePath = undefined; + testState.sessionConfig = undefined; + await fs.writeFile(configPath, "{}\n", "utf-8"); + clearRuntimeConfigSnapshot(); + clearConfigCache(); +}); + test("sessions.delete closes ACP runtime handles before removing ACP sessions", async () => { const { dir } = await createSessionStoreDir(); await writeSingleLineSession(dir, "sess-main", "hello"); diff --git a/src/gateway/server.sessions.list-changed.test.ts b/src/gateway/server.sessions.list-changed.test.ts index e2eb18f02c67..0c2f00b0011c 100644 --- a/src/gateway/server.sessions.list-changed.test.ts +++ b/src/gateway/server.sessions.list-changed.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { expect, test, vi } from "vitest"; -import { rpcReq, testState, writeSessionStore } from "./test-helpers.js"; +import { embeddedRunMock, rpcReq, testState, writeSessionStore } from "./test-helpers.js"; import { setupGatewaySessionsTestHarness, getGatewayConfigModule, @@ -561,6 +561,268 @@ test("sessions.changed mutation events include sendPolicy metadata", async () => }); }); +test("sessions.patch scopes selected global mutations and events to the requested agent", async () => { + const { dir } = await createSessionStoreDir(); + const storeTemplate = path.join(dir, "{agentId}", "sessions.json"); + testState.sessionStorePath = storeTemplate; + testState.sessionConfig = { scope: "global" }; + await writeSessionStore({ + entries: {}, + storePath: path.join(dir, "prime-sessions.json"), + }); + const mainStorePath = storeTemplate.replace("{agentId}", "main"); + const workStorePath = storeTemplate.replace("{agentId}", "work"); + await fs.mkdir(path.dirname(mainStorePath), { recursive: true }); + await fs.mkdir(path.dirname(workStorePath), { recursive: true }); + await fs.writeFile( + mainStorePath, + JSON.stringify({ global: sessionStoreEntry("sess-main-global") }, null, 2), + "utf-8", + ); + await fs.writeFile( + workStorePath, + JSON.stringify({ global: sessionStoreEntry("sess-work-global") }, null, 2), + "utf-8", + ); + const configPath = process.env.OPENCLAW_CONFIG_PATH; + if (!configPath) { + throw new Error("OPENCLAW_CONFIG_PATH is required"); + } + await fs.writeFile( + configPath, + `${JSON.stringify( + { + agents: { list: [{ id: "main", default: true }, { id: "work" }] }, + session: { scope: "global", store: storeTemplate }, + }, + null, + 2, + )}\n`, + "utf-8", + ); + const { clearConfigCache, clearRuntimeConfigSnapshot, getRuntimeConfig } = + await getGatewayConfigModule(); + clearRuntimeConfigSnapshot(); + clearConfigCache(); + + const broadcastToConnIds = vi.fn(); + const respond = vi.fn(); + const sessionsHandlers = await getSessionsHandlers(); + await sessionsHandlers["sessions.patch"]({ + req: {} as never, + params: { + key: "global", + agentId: "work", + label: "Work global", + }, + respond, + context: { + broadcastToConnIds, + getSessionEventSubscriberConnIds: () => new Set(["conn-1"]), + loadGatewayModelCatalog: async () => ({ providers: [] }), + getRuntimeConfig, + } as never, + client: null, + isWebchatConnect: () => false, + }); + + const responsePayload = expectRespondPayload(respond); + expectFields(responsePayload, { ok: true, key: "global" }); + expectChangedBroadcast(broadcastToConnIds, { + sessionKey: "global", + agentId: "work", + reason: "patch", + label: "Work global", + }); + const mainStore = JSON.parse(await fs.readFile(mainStorePath, "utf-8")) as { + global?: { label?: string }; + }; + const workStore = JSON.parse(await fs.readFile(workStorePath, "utf-8")) as { + global?: { label?: string }; + }; + expect(mainStore.global?.label).toBeUndefined(); + expect(workStore.global?.label).toBe("Work global"); + testState.sessionStorePath = undefined; + testState.sessionConfig = undefined; + await fs.writeFile(configPath, "{}\n", "utf-8"); + clearRuntimeConfigSnapshot(); + clearConfigCache(); +}); + +test("sessions.compact scopes selected global truncation to the requested agent", async () => { + const { dir } = await createSessionStoreDir(); + const storeTemplate = path.join(dir, "{agentId}", "sessions.json"); + testState.sessionStorePath = storeTemplate; + testState.sessionConfig = { scope: "global" }; + const mainStorePath = storeTemplate.replace("{agentId}", "main"); + const workStorePath = storeTemplate.replace("{agentId}", "work"); + const mainTranscript = path.join(path.dirname(mainStorePath), "sess-main-global.jsonl"); + const workTranscript = path.join(path.dirname(workStorePath), "sess-work-global.jsonl"); + await fs.mkdir(path.dirname(mainStorePath), { recursive: true }); + await fs.mkdir(path.dirname(workStorePath), { recursive: true }); + await fs.writeFile(mainTranscript, "main one\nmain two\n", "utf-8"); + await fs.writeFile(workTranscript, "work one\nwork two\n", "utf-8"); + await fs.writeFile( + mainStorePath, + JSON.stringify( + { global: sessionStoreEntry("sess-main-global", { sessionFile: mainTranscript }) }, + null, + 2, + ), + "utf-8", + ); + await fs.writeFile( + workStorePath, + JSON.stringify( + { global: sessionStoreEntry("sess-work-global", { sessionFile: workTranscript }) }, + null, + 2, + ), + "utf-8", + ); + const configPath = process.env.OPENCLAW_CONFIG_PATH; + if (!configPath) { + throw new Error("OPENCLAW_CONFIG_PATH is required"); + } + await fs.writeFile( + configPath, + `${JSON.stringify( + { + agents: { list: [{ id: "main", default: true }, { id: "work" }] }, + session: { scope: "global", store: storeTemplate }, + }, + null, + 2, + )}\n`, + "utf-8", + ); + const { clearConfigCache, clearRuntimeConfigSnapshot, getRuntimeConfig } = + await getGatewayConfigModule(); + clearRuntimeConfigSnapshot(); + clearConfigCache(); + + const broadcastToConnIds = vi.fn(); + const respond = vi.fn(); + const sessionsHandlers = await getSessionsHandlers(); + await sessionsHandlers["sessions.compact"]({ + req: {} as never, + params: { + key: "global", + agentId: "work", + maxLines: 1, + }, + respond, + context: { + broadcastToConnIds, + getSessionEventSubscriberConnIds: () => new Set(["conn-1"]), + getRuntimeConfig, + } as never, + client: null, + isWebchatConnect: () => false, + }); + + const responsePayload = expectRespondPayload(respond); + expectFields(responsePayload, { ok: true, key: "global", compacted: true, kept: 1 }); + expectChangedBroadcast(broadcastToConnIds, { + sessionKey: "global", + agentId: "work", + reason: "compact", + compacted: true, + }); + await expect(fs.readFile(mainTranscript, "utf-8")).resolves.toBe("main one\nmain two\n"); + await expect(fs.readFile(workTranscript, "utf-8")).resolves.toBe("work two\n"); + testState.sessionStorePath = undefined; + testState.sessionConfig = undefined; + await fs.writeFile(configPath, "{}\n", "utf-8"); + clearRuntimeConfigSnapshot(); + clearConfigCache(); +}); + +test("sessions.compact passes the selected global agent into embedded compaction", async () => { + const { dir } = await createSessionStoreDir(); + const storeTemplate = path.join(dir, "{agentId}", "sessions.json"); + testState.sessionStorePath = storeTemplate; + testState.sessionConfig = { scope: "global" }; + const mainStorePath = storeTemplate.replace("{agentId}", "main"); + const workStorePath = storeTemplate.replace("{agentId}", "work"); + const mainTranscript = path.join(path.dirname(mainStorePath), "sess-main-global.jsonl"); + const workTranscript = path.join(path.dirname(workStorePath), "sess-work-global.jsonl"); + await fs.mkdir(path.dirname(mainStorePath), { recursive: true }); + await fs.mkdir(path.dirname(workStorePath), { recursive: true }); + await fs.writeFile(mainTranscript, "main one\nmain two\n", "utf-8"); + await fs.writeFile(workTranscript, "work one\nwork two\n", "utf-8"); + await fs.writeFile( + mainStorePath, + JSON.stringify( + { global: sessionStoreEntry("sess-main-global", { sessionFile: mainTranscript }) }, + null, + 2, + ), + "utf-8", + ); + await fs.writeFile( + workStorePath, + JSON.stringify( + { global: sessionStoreEntry("sess-work-global", { sessionFile: workTranscript }) }, + null, + 2, + ), + "utf-8", + ); + const configPath = process.env.OPENCLAW_CONFIG_PATH; + if (!configPath) { + throw new Error("OPENCLAW_CONFIG_PATH is required"); + } + await fs.writeFile( + configPath, + `${JSON.stringify( + { + agents: { list: [{ id: "main", default: true }, { id: "work" }] }, + session: { scope: "global", store: storeTemplate }, + }, + null, + 2, + )}\n`, + "utf-8", + ); + const { clearConfigCache, clearRuntimeConfigSnapshot, getRuntimeConfig } = + await getGatewayConfigModule(); + clearRuntimeConfigSnapshot(); + clearConfigCache(); + + const respond = vi.fn(); + const sessionsHandlers = await getSessionsHandlers(); + await sessionsHandlers["sessions.compact"]({ + req: {} as never, + params: { + key: "global", + agentId: "work", + }, + respond, + context: { + broadcastToConnIds: vi.fn(), + getSessionEventSubscriberConnIds: () => new Set(), + getRuntimeConfig, + } as never, + client: null, + isWebchatConnect: () => false, + }); + + const responsePayload = expectRespondPayload(respond); + expectFields(responsePayload, { ok: true, key: "global", compacted: true }); + expect(embeddedRunMock.compactEmbeddedAgentSession).toHaveBeenCalledTimes(1); + expect(embeddedRunMock.compactEmbeddedAgentSession.mock.calls[0]?.[0]).toMatchObject({ + sessionId: "sess-work-global", + sessionKey: "global", + agentId: "work", + }); + testState.sessionStorePath = undefined; + testState.sessionConfig = undefined; + await fs.writeFile(configPath, "{}\n", "utf-8"); + clearRuntimeConfigSnapshot(); + clearConfigCache(); +}); + test("sessions.changed mutation events include subagent ownership metadata", async () => { await createSessionStoreDir(); await writeSessionStore({ diff --git a/src/gateway/server.sessions.reset-hooks.test.ts b/src/gateway/server.sessions.reset-hooks.test.ts index 1fe883fb199a..e34417fa2a21 100644 --- a/src/gateway/server.sessions.reset-hooks.test.ts +++ b/src/gateway/server.sessions.reset-hooks.test.ts @@ -1,6 +1,6 @@ import fs from "node:fs/promises"; import path from "node:path"; -import { expect, test } from "vitest"; +import { expect, test, vi } from "vitest"; import { embeddedRunMock, testState, writeSessionStore } from "./test-helpers.js"; import { setupGatewaySessionsTestHarness, @@ -154,6 +154,229 @@ test("sessions.reset emits before_reset hook with transcript context", async () expectMainHookContext(context, "sess-main"); }); +test("sessions.reset infers selected global agent from agent-prefixed aliases", async () => { + const { dir } = await createSessionStoreDir(); + const storeTemplate = path.join(dir, "{agentId}", "sessions.json"); + testState.sessionStorePath = storeTemplate; + testState.sessionConfig = { scope: "global" }; + await writeSessionStore({ + entries: {}, + storePath: path.join(dir, "prime-sessions.json"), + }); + const mainStorePath = storeTemplate.replace("{agentId}", "main"); + const workStorePath = storeTemplate.replace("{agentId}", "work"); + await fs.mkdir(path.dirname(mainStorePath), { recursive: true }); + await fs.mkdir(path.dirname(workStorePath), { recursive: true }); + await fs.writeFile( + mainStorePath, + JSON.stringify({ global: sessionStoreEntry("sess-main-global") }, null, 2), + "utf-8", + ); + await fs.writeFile( + workStorePath, + JSON.stringify({ global: sessionStoreEntry("sess-work-global") }, null, 2), + "utf-8", + ); + const configPath = expectStringValue(process.env.OPENCLAW_CONFIG_PATH, "OPENCLAW_CONFIG_PATH"); + await fs.writeFile( + configPath, + `${JSON.stringify( + { + agents: { list: [{ id: "main", default: true }, { id: "work" }] }, + session: { scope: "global", store: storeTemplate }, + }, + null, + 2, + )}\n`, + "utf-8", + ); + const { clearConfigCache, clearRuntimeConfigSnapshot, getRuntimeConfig } = + await import("../config/config.js"); + const { resolveGatewaySessionStoreTarget } = await import("./session-utils.js"); + clearRuntimeConfigSnapshot(); + clearConfigCache(); + + const { performGatewaySessionReset } = await import("./session-reset-service.js"); + const reset = await performGatewaySessionReset({ + key: "agent:work:main", + reason: "reset", + commandSource: "gateway:sessions.reset", + }); + + expect(reset.ok).toBe(true); + if (!reset.ok) { + throw new Error("expected reset to succeed"); + } + expect(reset.key).toBe("global"); + const resetTarget = resolveGatewaySessionStoreTarget({ + cfg: getRuntimeConfig(), + key: "agent:work:main", + agentId: "work", + }); + expect(resetTarget.storePath).toBe(workStorePath); + const mainStore = JSON.parse(await fs.readFile(mainStorePath, "utf-8")) as { + global?: { sessionId?: string }; + }; + const workStore = JSON.parse(await fs.readFile(resetTarget.storePath, "utf-8")) as { + global?: { sessionId?: string }; + }; + expect(mainStore.global?.sessionId).toBe("sess-main-global"); + expect(workStore.global?.sessionId).toBe(reset.entry.sessionId); + expect(workStore.global?.sessionId).not.toBe("sess-work-global"); + testState.sessionStorePath = undefined; + testState.sessionConfig = undefined; + await fs.writeFile(configPath, "{}\n", "utf-8"); + clearRuntimeConfigSnapshot(); + clearConfigCache(); +}); + +test("sessions.reset rejects selected global agentId conflicts", async () => { + const { dir } = await createSessionStoreDir(); + const storeTemplate = path.join(dir, "{agentId}", "sessions.json"); + testState.sessionStorePath = storeTemplate; + testState.sessionConfig = { scope: "global" }; + const configPath = expectStringValue(process.env.OPENCLAW_CONFIG_PATH, "OPENCLAW_CONFIG_PATH"); + await fs.writeFile( + configPath, + `${JSON.stringify( + { + agents: { list: [{ id: "main", default: true }, { id: "work" }] }, + session: { scope: "global", store: storeTemplate }, + }, + null, + 2, + )}\n`, + "utf-8", + ); + const { clearConfigCache, clearRuntimeConfigSnapshot } = await import("../config/config.js"); + clearRuntimeConfigSnapshot(); + clearConfigCache(); + + const { performGatewaySessionReset } = await import("./session-reset-service.js"); + const reset = await performGatewaySessionReset({ + key: "agent:main:main", + agentId: "work", + reason: "reset", + commandSource: "gateway:sessions.reset", + }); + + expect(reset.ok).toBe(false); + if (reset.ok) { + throw new Error("expected reset to fail"); + } + expect(reset.error.message).toBe("session key agent does not match agentId"); + testState.sessionStorePath = undefined; + testState.sessionConfig = undefined; + await fs.writeFile(configPath, "{}\n", "utf-8"); + clearRuntimeConfigSnapshot(); + clearConfigCache(); +}); + +test("sessions.reset rejects unknown selected global agents", async () => { + const { dir } = await createSessionStoreDir(); + const storeTemplate = path.join(dir, "{agentId}", "sessions.json"); + const configPath = expectStringValue(process.env.OPENCLAW_CONFIG_PATH, "OPENCLAW_CONFIG_PATH"); + const { clearConfigCache, clearRuntimeConfigSnapshot } = await import("../config/config.js"); + testState.sessionStorePath = storeTemplate; + testState.sessionConfig = { scope: "global" }; + await fs.writeFile( + configPath, + `${JSON.stringify( + { + agents: { list: [{ id: "main", default: true }, { id: "work" }] }, + session: { scope: "global", store: storeTemplate }, + }, + null, + 2, + )}\n`, + "utf-8", + ); + clearRuntimeConfigSnapshot(); + clearConfigCache(); + + try { + const { performGatewaySessionReset } = await import("./session-reset-service.js"); + const reset = await performGatewaySessionReset({ + key: "agent:typo:main", + reason: "reset", + commandSource: "gateway:sessions.reset", + }); + + expect(reset.ok).toBe(false); + if (reset.ok) { + throw new Error("expected reset to fail"); + } + expect(reset.error.message).toBe("Unknown agent id: typo"); + } finally { + testState.sessionStorePath = undefined; + testState.sessionConfig = undefined; + await fs.writeFile(configPath, "{}\n", "utf-8"); + clearRuntimeConfigSnapshot(); + clearConfigCache(); + } +}); + +test("sessions.reset emits inferred selected global agent scope", async () => { + const { dir } = await createSessionStoreDir(); + const storeTemplate = path.join(dir, "{agentId}", "sessions.json"); + const workStorePath = storeTemplate.replace("{agentId}", "work"); + const configPath = expectStringValue(process.env.OPENCLAW_CONFIG_PATH, "OPENCLAW_CONFIG_PATH"); + const { clearConfigCache, clearRuntimeConfigSnapshot } = await import("../config/config.js"); + testState.sessionStorePath = storeTemplate; + testState.sessionConfig = { scope: "global" }; + await fs.mkdir(path.dirname(workStorePath), { recursive: true }); + await fs.writeFile( + workStorePath, + JSON.stringify({ global: sessionStoreEntry("sess-work-global") }, null, 2), + "utf-8", + ); + await fs.writeFile( + configPath, + `${JSON.stringify( + { + agents: { list: [{ id: "main", default: true }, { id: "work" }] }, + session: { scope: "global", store: storeTemplate }, + }, + null, + 2, + )}\n`, + "utf-8", + ); + clearRuntimeConfigSnapshot(); + clearConfigCache(); + + try { + const broadcast = vi.fn(); + const reset = await directSessionReq<{ ok: true; key: string }>( + "sessions.reset", + { key: "agent:work:main", reason: "reset" }, + { + context: { + broadcastToConnIds: broadcast, + getSessionEventSubscriberConnIds: () => new Set(["conn-work"]), + }, + }, + ); + + expect(reset.ok).toBe(true); + expect(broadcast.mock.calls[0]?.[0]).toBe("sessions.changed"); + expect(broadcast.mock.calls[0]?.[1]).toEqual( + expect.objectContaining({ + sessionKey: "global", + agentId: "work", + reason: "reset", + }), + ); + expect(broadcast.mock.calls[0]?.[2]).toEqual(new Set(["conn-work"])); + } finally { + testState.sessionStorePath = undefined; + testState.sessionConfig = undefined; + await fs.writeFile(configPath, "{}\n", "utf-8"); + clearRuntimeConfigSnapshot(); + clearConfigCache(); + } +}); + test("sessions.reset emits enriched session_end and session_start hooks", async () => { const { dir } = await createSessionStoreDir(); const transcriptPath = path.join(dir, "sess-main.jsonl"); diff --git a/src/gateway/session-lifecycle-state.ts b/src/gateway/session-lifecycle-state.ts index 18dfe4c1b3a0..1de6254bcea3 100644 --- a/src/gateway/session-lifecycle-state.ts +++ b/src/gateway/session-lifecycle-state.ts @@ -145,6 +145,7 @@ export function derivePersistedSessionLifecyclePatch(params: { export async function persistGatewaySessionLifecycleEvent(params: { sessionKey: string; + agentId?: string; event: LifecycleEventLike; }): Promise { const phase = resolveLifecyclePhase(params.event); @@ -152,7 +153,10 @@ export async function persistGatewaySessionLifecycleEvent(params: { return; } - const sessionEntry = loadSessionEntry(params.sessionKey, { clone: false }); + const sessionEntry = loadSessionEntry(params.sessionKey, { + ...(params.agentId ? { agentId: params.agentId } : {}), + clone: false, + }); if (!sessionEntry.entry) { return; } diff --git a/src/gateway/session-message-events.test.ts b/src/gateway/session-message-events.test.ts index c4bf0959d37e..f0a62725da46 100644 --- a/src/gateway/session-message-events.test.ts +++ b/src/gateway/session-message-events.test.ts @@ -68,6 +68,7 @@ async function withOperatorSessionSubscriber( function waitForSessionMessageEvent( ws: Awaited>["openWs"]>>, sessionKey: string, + timeoutMs?: number, ) { return onceMessage( ws, @@ -75,6 +76,7 @@ function waitForSessionMessageEvent( message.type === "event" && message.event === "session.message" && (message.payload as { sessionKey?: string } | undefined)?.sessionKey === sessionKey, + timeoutMs, ); } @@ -99,6 +101,7 @@ async function emitTranscriptUpdateAndCollectMessageEvent(params: { sessionFile: string; message: Record; messageId: string; + agentId?: string; messageSeq?: number; }) { const messageEventPromise = waitForSessionMessageEvent(params.ws, params.sessionKey); @@ -106,6 +109,7 @@ async function emitTranscriptUpdateAndCollectMessageEvent(params: { emitSessionTranscriptUpdate({ sessionFile: params.sessionFile, sessionKey: params.sessionKey, + ...(params.agentId ? { agentId: params.agentId } : {}), message: params.message, messageId: params.messageId, ...(typeof params.messageSeq === "number" ? { messageSeq: params.messageSeq } : {}), @@ -151,6 +155,17 @@ describe("session.message websocket events", () => { child: { sessionId: "sess-child", updatedAt: Date.now(), + goal: { + schemaVersion: 1, + id: "goal-child", + objective: "Finish child work", + status: "active", + createdAt: 1, + updatedAt: 2, + tokenStart: 0, + tokensUsed: 42, + continuationTurns: 0, + }, spawnedBy: "agent:main:parent", spawnedWorkspaceDir: "/tmp/subagent-workspace", spawnedCwd: "/tmp/task-repo", @@ -191,6 +206,18 @@ describe("session.message websocket events", () => { subagentRole: "orchestrator", subagentControlScope: "children", displayName: "Ops Child", + goal: { + schemaVersion: 1, + id: "goal-child", + objective: "Finish child work", + status: "active", + createdAt: 1, + updatedAt: 2, + tokenStart: 0, + tokenStartFresh: true, + tokensUsed: 42, + continuationTurns: 0, + }, }); }); }); @@ -435,6 +462,7 @@ describe("session.message websocket events", () => { expectRecordFields(changedEvent.payload, { sessionKey: "agent:main:hidden-runtime", phase: "message", + goal: null, }); }); }); @@ -642,6 +670,271 @@ describe("session.message websocket events", () => { } }); + test("routes selected-agent global transcript updates to matching message subscribers", async () => { + const storePath = await createSessionStoreFile(); + testState.agentsConfig = { list: [{ id: "main", default: true }, { id: "work" }] }; + const transcriptPath = path.join(path.dirname(storePath), "global-work.jsonl"); + await writeSessionStore({ + entries: { + global: { + sessionId: "sess-work-global", + sessionFile: transcriptPath, + updatedAt: Date.now(), + goal: { + schemaVersion: 1, + id: "goal-work-global", + objective: "Finish work global task", + status: "active", + createdAt: 1, + updatedAt: 2, + tokenStart: 0, + tokensUsed: 5, + continuationTurns: 0, + }, + }, + }, + storePath, + agentId: "work", + }); + const transcriptMessage = { + role: "user", + content: [{ type: "text", text: "work selected global prompt" }], + timestamp: Date.now(), + }; + await fs.writeFile( + transcriptPath, + [ + JSON.stringify({ type: "session", version: 1, id: "sess-work-global" }), + JSON.stringify({ id: "msg-work-global", message: transcriptMessage }), + ].join("\n"), + "utf-8", + ); + + const workWs = await harness.openWs(); + const mainWs = await harness.openWs(); + const bareWs = await harness.openWs(); + try { + await connectOk(workWs, { scopes: ["operator.read"] }); + await connectOk(mainWs, { scopes: ["operator.read"] }); + await connectOk(bareWs, { scopes: ["operator.read"] }); + await rpcReq(workWs, "sessions.messages.subscribe", { + key: "global", + agentId: "work", + }); + await rpcReq(mainWs, "sessions.messages.subscribe", { + key: "global", + agentId: "main", + }); + await rpcReq(bareWs, "sessions.messages.subscribe", { + key: "global", + }); + + const workMessagePromise = waitForSessionMessageEvent(workWs, "global"); + const mainMessagePromise = expectNoMessageWithin({ + watch: (timeoutMs) => waitForSessionMessageEvent(mainWs, "global", timeoutMs), + timeoutMs: 250, + }); + const bareMessagePromise = expectNoMessageWithin({ + watch: (timeoutMs) => waitForSessionMessageEvent(bareWs, "global", timeoutMs), + timeoutMs: 250, + }); + emitSessionTranscriptUpdate({ + sessionFile: transcriptPath, + sessionKey: "global", + agentId: "work", + message: transcriptMessage, + messageId: "msg-work-global", + }); + + const workMessage = await workMessagePromise; + await mainMessagePromise; + await bareMessagePromise; + expectRecordFields(workMessage.payload, { + sessionKey: "global", + agentId: "work", + messageId: "msg-work-global", + goal: { + schemaVersion: 1, + id: "goal-work-global", + objective: "Finish work global task", + status: "active", + createdAt: 1, + updatedAt: 2, + tokenStart: 0, + tokenStartFresh: true, + tokensUsed: 5, + continuationTurns: 0, + }, + }); + } finally { + workWs.close(); + mainWs.close(); + bareWs.close(); + testState.agentsConfig = undefined; + testState.sessionStorePath = undefined; + } + }); + + test("routes unscoped global transcript events to default-agent global subscribers", async () => { + const storePath = await createSessionStoreFile(); + const transcriptPath = path.join(path.dirname(storePath), "sess-default-global.jsonl"); + await writeSessionStore({ + entries: { + global: { + sessionId: "sess-default-global", + sessionFile: transcriptPath, + updatedAt: Date.now(), + }, + }, + storePath, + }); + const transcriptMessage = { + role: "assistant", + content: [{ type: "text", text: "default global prompt" }], + timestamp: Date.now(), + }; + await fs.writeFile( + transcriptPath, + [ + JSON.stringify({ type: "session", version: 1, id: "sess-default-global" }), + JSON.stringify({ id: "msg-default-global", message: transcriptMessage }), + ].join("\n"), + "utf-8", + ); + + const workWs = await harness.openWs(); + const mainWs = await harness.openWs(); + const bareWs = await harness.openWs(); + try { + await connectOk(workWs, { scopes: ["operator.read"] }); + await connectOk(mainWs, { scopes: ["operator.read"] }); + await connectOk(bareWs, { scopes: ["operator.read"] }); + await rpcReq(workWs, "sessions.messages.subscribe", { + key: "global", + agentId: "work", + }); + await rpcReq(mainWs, "sessions.messages.subscribe", { + key: "global", + agentId: "main", + }); + await rpcReq(bareWs, "sessions.messages.subscribe", { + key: "global", + }); + + const mainMessagePromise = waitForSessionMessageEvent(mainWs, "global"); + const bareMessagePromise = waitForSessionMessageEvent(bareWs, "global"); + const workMessagePromise = expectNoMessageWithin({ + watch: (timeoutMs) => waitForSessionMessageEvent(workWs, "global", timeoutMs), + timeoutMs: 250, + }); + emitSessionTranscriptUpdate({ + sessionFile: transcriptPath, + sessionKey: "global", + message: transcriptMessage, + messageId: "msg-default-global", + }); + + const mainMessage = await mainMessagePromise; + const bareMessage = await bareMessagePromise; + await workMessagePromise; + expectRecordFields(mainMessage.payload, { + sessionKey: "global", + messageId: "msg-default-global", + }); + expectRecordFields(bareMessage.payload, { + sessionKey: "global", + messageId: "msg-default-global", + }); + expect((mainMessage.payload as { agentId?: unknown }).agentId).toBeUndefined(); + expect((bareMessage.payload as { agentId?: unknown }).agentId).toBeUndefined(); + } finally { + workWs.close(); + mainWs.close(); + bareWs.close(); + } + }); + + test("routes default-agent scoped global transcript events to legacy global subscribers", async () => { + const storePath = await createSessionStoreFile(); + const transcriptPath = path.join(path.dirname(storePath), "sess-default-scoped-global.jsonl"); + await writeSessionStore({ + entries: { + global: { + sessionId: "sess-default-scoped-global", + sessionFile: transcriptPath, + updatedAt: Date.now(), + }, + }, + storePath, + agentId: "main", + }); + const transcriptMessage = { + role: "assistant", + content: [{ type: "text", text: "default scoped global prompt" }], + timestamp: Date.now(), + }; + await fs.writeFile( + transcriptPath, + [ + JSON.stringify({ type: "session", version: 1, id: "sess-default-scoped-global" }), + JSON.stringify({ id: "msg-default-scoped-global", message: transcriptMessage }), + ].join("\n"), + "utf-8", + ); + + const workWs = await harness.openWs(); + const mainWs = await harness.openWs(); + const bareWs = await harness.openWs(); + try { + await connectOk(workWs, { scopes: ["operator.read"] }); + await connectOk(mainWs, { scopes: ["operator.read"] }); + await connectOk(bareWs, { scopes: ["operator.read"] }); + await rpcReq(workWs, "sessions.messages.subscribe", { + key: "global", + agentId: "work", + }); + await rpcReq(mainWs, "sessions.messages.subscribe", { + key: "global", + agentId: "main", + }); + await rpcReq(bareWs, "sessions.messages.subscribe", { + key: "global", + }); + + const mainMessagePromise = waitForSessionMessageEvent(mainWs, "global"); + const bareMessagePromise = waitForSessionMessageEvent(bareWs, "global"); + const workMessagePromise = expectNoMessageWithin({ + watch: (timeoutMs) => waitForSessionMessageEvent(workWs, "global", timeoutMs), + timeoutMs: 250, + }); + emitSessionTranscriptUpdate({ + sessionFile: transcriptPath, + sessionKey: "global", + agentId: "main", + message: transcriptMessage, + messageId: "msg-default-scoped-global", + }); + + const mainMessage = await mainMessagePromise; + const bareMessage = await bareMessagePromise; + await workMessagePromise; + expectRecordFields(mainMessage.payload, { + sessionKey: "global", + agentId: "main", + messageId: "msg-default-scoped-global", + }); + expectRecordFields(bareMessage.payload, { + sessionKey: "global", + agentId: "main", + messageId: "msg-default-scoped-global", + }); + } finally { + workWs.close(); + mainWs.close(); + bareWs.close(); + } + }); + test("includes spawnedBy metadata on session.message transcript events", async () => { const storePath = await createSessionStoreFile(); const transcriptPath = path.join(path.dirname(storePath), "sess-child.jsonl"); diff --git a/src/gateway/session-reset-service.ts b/src/gateway/session-reset-service.ts index 699c7c75db34..de796417aea8 100644 --- a/src/gateway/session-reset-service.ts +++ b/src/gateway/session-reset-service.ts @@ -6,7 +6,11 @@ import { getAcpSessionManager } from "../acp/control-plane/manager.js"; import { getAcpRuntimeBackend } from "../acp/runtime/registry.js"; import { readAcpSessionEntry, upsertAcpSessionMeta } from "../acp/runtime/session-meta.js"; import { retireSessionMcpRuntime } from "../agents/agent-bundle-mcp-tools.js"; -import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { + listAgentIds, + resolveAgentWorkspaceDir, + resolveDefaultAgentId, +} from "../agents/agent-scope.js"; import { clearBootstrapSnapshot } from "../agents/bootstrap-cache.js"; import { clearAllCliSessions } from "../agents/cli-session.js"; import { abortEmbeddedAgentRun, waitForEmbeddedAgentRunEnd } from "../agents/embedded-agent.js"; @@ -59,6 +63,7 @@ import { migrateAndPruneGatewaySessionStoreKey, readSessionMessagesAsync, resolveGatewaySessionStoreTarget, + resolveSessionStoreKey, resolveSessionModelRef, } from "./session-utils.js"; @@ -702,18 +707,55 @@ export async function emitGatewayBeforeResetPluginHook(params: { export async function performGatewaySessionReset(params: { key: string; + agentId?: string; reason: "new" | "reset"; commandSource: string; }): Promise< - | { ok: true; key: string; entry: SessionEntry } + | { ok: true; key: string; entry: SessionEntry; agentId: string } | { ok: false; error: ReturnType } > { - const { cfg, target, storePath } = (() => { + const resetTarget = (() => { const cfg = getRuntimeConfig(); - const target = resolveGatewaySessionStoreTarget({ cfg, key: params.key }); - return { cfg, target, storePath: target.storePath }; + const explicitAgentId = params.agentId ? normalizeAgentId(params.agentId) : undefined; + const parsedKey = parseAgentSessionKey(params.key); + const inferredGlobalAgentId = + !explicitAgentId && + parsedKey && + resolveSessionStoreKey({ cfg, sessionKey: params.key }) === "global" + ? normalizeAgentId(parsedKey.agentId) + : undefined; + const requestedAgentId = explicitAgentId ?? inferredGlobalAgentId; + if (requestedAgentId && !listAgentIds(cfg).includes(requestedAgentId)) { + return { + ok: false as const, + error: errorShape(ErrorCodes.INVALID_REQUEST, `Unknown agent id: ${requestedAgentId}`), + }; + } + if ( + explicitAgentId && + parsedKey?.agentId && + normalizeAgentId(parsedKey.agentId) !== explicitAgentId + ) { + return { + ok: false as const, + error: errorShape(ErrorCodes.INVALID_REQUEST, "session key agent does not match agentId"), + }; + } + const target = resolveGatewaySessionStoreTarget({ + cfg, + key: params.key, + ...(requestedAgentId ? { agentId: requestedAgentId } : {}), + }); + return { ok: true as const, cfg, target, storePath: target.storePath, requestedAgentId }; })(); - const { entry, legacyKey, canonicalKey } = loadSessionEntry(params.key); + if (!resetTarget.ok) { + return resetTarget; + } + const { cfg, target, storePath, requestedAgentId } = resetTarget; + const { entry, legacyKey, canonicalKey } = loadSessionEntry( + params.key, + requestedAgentId ? { agentId: requestedAgentId } : undefined, + ); const hadExistingEntry = Boolean(entry); const agentId = normalizeAgentId(target.agentId ?? resolveDefaultAgentId(cfg)); const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId); @@ -751,11 +793,14 @@ export async function performGatewaySessionReset(params: { cfg, key: params.key, store, + ...(requestedAgentId ? { agentId: requestedAgentId } : {}), }); const currentEntry = store[primaryKey]; resetSourceEntry = currentEntry ? { ...currentEntry } : undefined; const parsed = parseAgentSessionKey(primaryKey); - const sessionAgentId = normalizeAgentId(parsed?.agentId ?? resolveDefaultAgentId(cfg)); + const sessionAgentId = normalizeAgentId( + parsed?.agentId ?? target.agentId ?? requestedAgentId ?? resolveDefaultAgentId(cfg), + ); const resetPreservedSelection = resolveResetPreservedSelection({ entry: currentEntry, }); @@ -916,5 +961,5 @@ export async function performGatewaySessionReset(params: { reason: "session-reset", }); } - return { ok: true, key: target.canonicalKey, entry: next }; + return { ok: true, key: target.canonicalKey, entry: next, agentId: target.agentId }; } diff --git a/src/gateway/session-utils.test.ts b/src/gateway/session-utils.test.ts index c6d581500919..2c206cc849ac 100644 --- a/src/gateway/session-utils.test.ts +++ b/src/gateway/session-utils.test.ts @@ -590,6 +590,46 @@ describe("gateway session utils", () => { }); }); + test("selected global rows read transcript usage from the selected agent", async () => { + await withStateDirEnv("session-utils-selected-global-usage-", async ({ stateDir }) => { + const sessionId = "selected-global-usage"; + for (const [agentId, input] of [ + ["main", 10], + ["work", 40], + ] as const) { + const sessionsDir = path.join(stateDir, "agents", agentId, "sessions"); + fs.mkdirSync(sessionsDir, { recursive: true }); + fs.writeFileSync( + path.join(sessionsDir, `${sessionId}.jsonl`), + [ + JSON.stringify({ type: "session", version: 1, id: sessionId }), + JSON.stringify({ + message: { + role: "assistant", + content: "done", + usage: { input, output: 2 }, + }, + }), + ].join("\n"), + "utf-8", + ); + } + + const row = buildGatewaySessionRow({ + cfg: { + agents: { list: [{ id: "main", default: true }, { id: "work" }] }, + } as OpenClawConfig, + storePath: "", + store: {}, + key: "global", + agentId: "work", + entry: { sessionId, updatedAt: 1 }, + }); + + expect(row.totalTokens).toBe(40); + }); + }); + test("session rows use per-agent thinking default from config", () => { const cfg = { agents: { @@ -1685,6 +1725,35 @@ describe("listSessionsFromStore selected model display", () => { ]); }); + test("keeps the scoped global row when filtering by agent", () => { + const now = Date.now(); + const result = listSessionsFromStore({ + cfg: { + ...createModelDefaultsConfig({ primary: "openai/gpt-5.4" }), + agents: { + defaults: { model: { primary: "openai/gpt-5.4" } }, + list: [ + { id: "main", default: true, model: { primary: "openai/gpt-5.4" } }, + { id: "work", model: { primary: "anthropic/claude-opus-4-6" } }, + ], + }, + } as OpenClawConfig, + storePath: "/tmp/sessions.json", + store: { + global: { sessionId: "global", updatedAt: now } as SessionEntry, + "agent:main:main": { sessionId: "main", updatedAt: now - 1 } as SessionEntry, + "agent:work:main": { sessionId: "work", updatedAt: now - 2 } as SessionEntry, + }, + opts: { agentId: "work", includeGlobal: true, search: "global" }, + }); + + expect(result.sessions.map((session) => session.key)).toEqual(["global"]); + expect(result.sessions[0]).toMatchObject({ + modelProvider: "anthropic", + model: "claude-opus-4-6", + }); + }); + test("shows the selected override model even when a fallback runtime model exists", () => { const cfg = createModelDefaultsConfig({ primary: "anthropic/claude-opus-4-6", diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index 4b49762c0893..1d0256d71f1d 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -52,6 +52,7 @@ import { resolveAllAgentSessionStoreTargetsSync, resolveAgentMainSessionKey, resolveFreshSessionTotalTokens, + resolveSessionGoalDisplayState, resolveStorePath, type SessionEntry, type SessionStoreTarget, @@ -809,6 +810,7 @@ function resolveTranscriptUsageFallback(params: { fallbackModel?: string; maxTranscriptBytes?: number; rowContext?: SessionListRowContext; + agentId?: string; }): { estimatedCostUsd?: number; totalTokens?: number; @@ -824,7 +826,7 @@ function resolveTranscriptUsageFallback(params: { const parsed = parseAgentSessionKey(params.key); const agentId = parsed?.agentId ? normalizeAgentId(parsed.agentId) - : resolveDefaultAgentId(params.cfg); + : normalizeAgentId(params.agentId ?? resolveDefaultAgentId(params.cfg)); const snapshot = readRecentSessionUsageFromTranscript( entry.sessionId, params.storePath, @@ -1019,11 +1021,13 @@ export function migrateAndPruneGatewaySessionStoreKey(params: { cfg: OpenClawConfig; key: string; store: Record; + agentId?: string; }) { const target = resolveGatewaySessionStoreTarget({ cfg: params.cfg, key: params.key, store: params.store, + ...(params.agentId ? { agentId: params.agentId } : {}), }); const primaryKey = target.canonicalKey; const freshestMatch = resolveFreshestSessionStoreMatchFromStoreKeys( @@ -1787,6 +1791,7 @@ export function buildGatewaySessionRow(params: { transcriptUsageMaxBytes?: number; storeChildSessionsByKey?: Map; rowContext?: SessionListRowContext; + agentId?: string; skipTranscriptUsageFallback?: boolean; lightweightListRow?: boolean; }): GatewaySessionRow { @@ -1819,7 +1824,9 @@ export function buildGatewaySessionRow(params: { originLabel; const deliveryFields = normalizeSessionDeliveryFields(entry); const parsedAgent = parseAgentSessionKey(key); - const sessionAgentId = normalizeAgentId(parsedAgent?.agentId ?? resolveDefaultAgentId(cfg)); + const sessionAgentId = normalizeAgentId( + parsedAgent?.agentId ?? params.agentId ?? resolveDefaultAgentId(cfg), + ); const rowContext = params.rowContext; const subagentRun = rowContext ? rowContext.subagentRuns.getDisplaySubagentRun(key) @@ -1912,6 +1919,7 @@ export function buildGatewaySessionRow(params: { fallbackModel: resolvedModel.model ?? DEFAULT_MODEL, maxTranscriptBytes: params.transcriptUsageMaxBytes, rowContext: params.rowContext, + agentId: sessionAgentId, }) : null; const preferLiveSubagentModelIdentity = @@ -1938,6 +1946,19 @@ export function buildGatewaySessionRow(params: { typeof totalTokens === "number" && Number.isFinite(totalTokens) && totalTokens > 0 ? true : transcriptUsage?.totalTokensFresh === true; + const goal = entry?.goal + ? resolveSessionGoalDisplayState( + { + goal: entry.goal, + totalTokens, + totalTokensFresh, + }, + now, + // Session listing is read-only; stale goal baselines are adopted only + // by goal commands/tools that can persist the first fresh snapshot. + { adoptFreshBaseline: false }, + ) + : undefined; const childSessions = params.storeChildSessionsByKey ? mergeChildSessionKeys( resolveRuntimeChildSessionKeys(key, now, rowContext?.subagentRuns), @@ -2065,6 +2086,7 @@ export function buildGatewaySessionRow(params: { outputTokens: entry?.outputTokens, totalTokens, totalTokensFresh, + goal, estimatedCostUsd, status: subagentRun ? subagentStatus : entry?.status, subagentRunState, @@ -2179,6 +2201,7 @@ function resolveSessionListSearchModelFields(params: { export function loadGatewaySessionRow( sessionKey: string, options?: { + agentId?: string; includeDerivedTitles?: boolean; includeLastMessage?: boolean; now?: number; @@ -2188,6 +2211,7 @@ export function loadGatewaySessionRow( const now = options?.now ?? Date.now(); const { cfg, storePath, store, entry, canonicalKey } = loadSessionEntry(sessionKey, { clone: false, + ...(options?.agentId ? { agentId: options.agentId } : {}), }); if (!entry) { return null; @@ -2209,6 +2233,7 @@ export function loadGatewaySessionRow( includeLastMessage: options?.includeLastMessage, transcriptUsageMaxBytes: options?.transcriptUsageMaxBytes, storeChildSessionsByKey, + ...(options?.agentId ? { agentId: options.agentId } : {}), }); } @@ -2324,7 +2349,10 @@ function filterSessionEntries(params: { return false; } if (agentId) { - if (key === "global" || key === "unknown") { + if (key === "global") { + return includeGlobal; + } + if (key === "unknown") { return false; } const parsed = parseAgentSessionKey(key); @@ -2472,12 +2500,17 @@ export function listSessionsFromStore(params: { const sessions = entries.map(([key, entry], index) => { const includeTranscriptFields = index < sessionListTranscriptFieldRows; + const rowAgentId = + key === "global" && typeof opts.agentId === "string" + ? normalizeAgentId(opts.agentId) + : undefined; return buildGatewaySessionRow({ cfg, storePath, store, key, entry, + agentId: rowAgentId, modelCatalog: params.modelCatalog, now, includeDerivedTitles: includeTranscriptFields && includeDerivedTitles, @@ -2549,12 +2582,17 @@ export async function listSessionsFromStoreAsync(params: { for (let i = 0; i < entries.length; i++) { const [key, entry] = entries[i]; const includeTranscriptFields = i < sessionListTranscriptFieldRows; + const rowAgentId = + key === "global" && typeof opts.agentId === "string" + ? normalizeAgentId(opts.agentId) + : undefined; const row = buildGatewaySessionRow({ cfg, storePath, store, key, entry, + agentId: rowAgentId, modelCatalog: params.modelCatalog, now, includeDerivedTitles: false, @@ -2571,9 +2609,9 @@ export async function listSessionsFromStoreAsync(params: { (includeDerivedTitles || includeLastMessage) ) { const parsed = parseAgentSessionKey(key); - const sessionAgentId = parsed?.agentId - ? normalizeAgentId(parsed.agentId) - : resolveDefaultAgentId(cfg); + const sessionAgentId = + rowAgentId ?? + (parsed?.agentId ? normalizeAgentId(parsed.agentId) : resolveDefaultAgentId(cfg)); const fields = await readSessionTitleFieldsFromTranscriptAsync( entry.sessionId, storePath, diff --git a/src/gateway/session-utils.types.ts b/src/gateway/session-utils.types.ts index a575b783deb3..1f610d35a354 100644 --- a/src/gateway/session-utils.types.ts +++ b/src/gateway/session-utils.types.ts @@ -1,5 +1,9 @@ import type { ChatType } from "../channels/chat-type.js"; -import type { SessionCompactionCheckpoint, SessionEntry } from "../config/sessions/types.js"; +import type { + SessionCompactionCheckpoint, + SessionEntry, + SessionGoal, +} from "../config/sessions/types.js"; import type { PluginSessionExtensionProjection } from "../plugins/host-hooks.js"; import type { GatewayAgentRuntime, @@ -70,6 +74,7 @@ export type GatewaySessionRow = { outputTokens?: number; totalTokens?: number; totalTokensFresh?: boolean; + goal?: SessionGoal; estimatedCostUsd?: number; status?: SessionRunStatus; hasActiveRun?: boolean; diff --git a/src/gateway/sessions-patch.test.ts b/src/gateway/sessions-patch.test.ts index 1af697f78ef4..d4975ce7b633 100644 --- a/src/gateway/sessions-patch.test.ts +++ b/src/gateway/sessions-patch.test.ts @@ -18,12 +18,14 @@ async function runPatch(params: { store?: Record; cfg?: OpenClawConfig; storeKey?: string; + agentId?: string; loadGatewayModelCatalog?: ApplySessionsPatchArgs["loadGatewayModelCatalog"]; }) { return applySessionsPatchToStore({ cfg: params.cfg ?? EMPTY_CFG, store: params.store ?? {}, storeKey: params.storeKey ?? MAIN_SESSION_KEY, + agentId: params.agentId, patch: params.patch, loadGatewayModelCatalog: params.loadGatewayModelCatalog, }); @@ -607,6 +609,37 @@ describe("gateway sessions patch", () => { expect(entry.thinkingLevel).toBe("xhigh"); }); + test("validates global patches against the selected agent", async () => { + const entry = expectPatchOk( + await runPatch({ + cfg: { + agents: { + list: [ + { + id: "main", + default: true, + model: { primary: "gmn/gpt-5.4" }, + }, + { + id: "work", + model: { primary: "openai-codex/gpt-5.5" }, + }, + ], + }, + } as OpenClawConfig, + storeKey: "global", + agentId: "work", + patch: { + key: "global", + thinkingLevel: "xhigh", + }, + loadGatewayModelCatalog: async () => [], + }), + ); + + expect(entry.thinkingLevel).toBe("xhigh"); + }); + test("accepts xhigh thinking patches from bundled startup-lazy provider policy without catalog", async () => { const entry = expectPatchOk( await runPatch({ diff --git a/src/gateway/sessions-patch.ts b/src/gateway/sessions-patch.ts index 3451825d23a4..0c6042901dbd 100644 --- a/src/gateway/sessions-patch.ts +++ b/src/gateway/sessions-patch.ts @@ -133,13 +133,16 @@ export async function applySessionsPatchToStore(params: { cfg: OpenClawConfig; store: Record; storeKey: string; + agentId?: string; patch: SessionsPatchParams; loadGatewayModelCatalog?: () => Promise; }): Promise<{ ok: true; entry: SessionEntry } | { ok: false; error: ErrorShape }> { const { cfg, store, storeKey, patch } = params; const now = Date.now(); const parsedAgent = parseAgentSessionKey(storeKey); - const sessionAgentId = normalizeAgentId(parsedAgent?.agentId ?? resolveDefaultAgentId(cfg)); + const sessionAgentId = normalizeAgentId( + params.agentId ?? parsedAgent?.agentId ?? resolveDefaultAgentId(cfg), + ); const resolvedDefault = resolveDefaultModelForAgent({ cfg, agentId: sessionAgentId }); const subagentModelHint = isSubagentSessionKey(storeKey) ? resolveSubagentConfiguredModelSelection({ cfg, agentId: sessionAgentId }) diff --git a/src/infra/agent-events.ts b/src/infra/agent-events.ts index 80d421f1460c..038f99817c84 100644 --- a/src/infra/agent-events.ts +++ b/src/infra/agent-events.ts @@ -106,6 +106,7 @@ export type AgentEventPayload = { ts: number; data: Record; sessionKey?: string; + agentId?: string; }; export type AgentRunContext = { diff --git a/src/plugins/session-entry-slot-keys.ts b/src/plugins/session-entry-slot-keys.ts index 81a17f995c5c..caa8f1a959c4 100644 --- a/src/plugins/session-entry-slot-keys.ts +++ b/src/plugins/session-entry-slot-keys.ts @@ -28,6 +28,7 @@ const SESSION_ENTRY_RESERVED_SLOT_KEY_LIST = [ "pluginOwnerId", "systemSent", "abortedLastRun", + "goal", "sessionStartedAt", "lastInteractionAt", "startedAt", diff --git a/src/sessions/transcript-events.test.ts b/src/sessions/transcript-events.test.ts index ee9157668bc3..753a2d920d3e 100644 --- a/src/sessions/transcript-events.test.ts +++ b/src/sessions/transcript-events.test.ts @@ -27,6 +27,7 @@ describe("transcript events", () => { emitSessionTranscriptUpdate({ sessionFile: " /tmp/session.jsonl ", sessionKey: " agent:main:main ", + agentId: " main ", message: { role: "assistant", content: "hi" }, messageId: " msg-1 ", messageSeq: 2, @@ -35,6 +36,7 @@ describe("transcript events", () => { expect(listener).toHaveBeenCalledWith({ sessionFile: "/tmp/session.jsonl", sessionKey: "agent:main:main", + agentId: "main", message: { role: "assistant", content: "hi" }, messageId: "msg-1", messageSeq: 2, diff --git a/src/sessions/transcript-events.ts b/src/sessions/transcript-events.ts index adc985043b10..57fad87b734c 100644 --- a/src/sessions/transcript-events.ts +++ b/src/sessions/transcript-events.ts @@ -4,6 +4,7 @@ import { normalizeOptionalString } from "../shared/string-coerce.js"; export type SessionTranscriptUpdate = { sessionFile: string; sessionKey?: string; + agentId?: string; message?: unknown; messageId?: string; messageSeq?: number; @@ -27,6 +28,7 @@ export function emitSessionTranscriptUpdate(update: string | SessionTranscriptUp : { sessionFile: update.sessionFile, sessionKey: update.sessionKey, + agentId: update.agentId, message: update.message, messageId: update.messageId, messageSeq: update.messageSeq, @@ -41,6 +43,9 @@ export function emitSessionTranscriptUpdate(update: string | SessionTranscriptUp ...(normalizeOptionalString(normalized.sessionKey) ? { sessionKey: normalizeOptionalString(normalized.sessionKey) } : {}), + ...(normalizeOptionalString(normalized.agentId) + ? { agentId: normalizeOptionalString(normalized.agentId) } + : {}), ...(normalized.message !== undefined ? { message: normalized.message } : {}), ...(normalizeOptionalString(normalized.messageId) ? { messageId: normalizeOptionalString(normalized.messageId) } diff --git a/src/sessions/user-turn-transcript.ts b/src/sessions/user-turn-transcript.ts index 21edc52a55bc..c6ad5a92b30a 100644 --- a/src/sessions/user-turn-transcript.ts +++ b/src/sessions/user-turn-transcript.ts @@ -410,6 +410,7 @@ export async function appendUserTurnTranscriptMessage( emitSessionTranscriptUpdate({ sessionFile: params.transcriptPath, ...(params.sessionKey ? { sessionKey: params.sessionKey } : {}), + ...(params.agentId ? { agentId: params.agentId } : {}), message: appended.message, messageId: appended.messageId, }); diff --git a/src/tui/embedded-backend.test.ts b/src/tui/embedded-backend.test.ts index bb8819dff26f..b5e3f9aa0a65 100644 --- a/src/tui/embedded-backend.test.ts +++ b/src/tui/embedded-backend.test.ts @@ -5,15 +5,34 @@ import { defaultRuntime } from "../runtime.js"; const agentCommandFromIngressMock = vi.fn(); const updateSessionStoreMock = vi.fn(); const applySessionsPatchToStoreMock = vi.fn(); +const createSessionGoalMock = vi.fn(); +const clearSessionGoalMock = vi.fn(); +const getSessionGoalMock = vi.fn(); +const updateSessionGoalStatusMock = vi.fn(); +const listSessionsFromStoreAsyncMock = vi.fn( + async (_options?: unknown): Promise<{ sessions: unknown[] }> => ({ sessions: [] }), +); +const loadCombinedSessionStoreForGatewayMock = vi.fn((_options?: unknown) => ({ + storePath: "/tmp/openclaw-sessions.json", + store: {}, +})); const getRuntimeConfigMock = vi.fn(() => ({})); const loadGatewayModelCatalogMock = vi.fn( (_params?: unknown): Array<{ id: string; name: string; provider: string }> => [], ); -const loadSessionEntryMock = vi.fn((sessionKey: string) => ({ - cfg: {}, - canonicalKey: sessionKey, - entry: {}, -})); +type LoadSessionEntryMockResult = { + cfg: Record; + canonicalKey: string; + storePath?: string; + entry?: Record; +}; +const loadSessionEntryMock = vi.fn( + (sessionKey: string, _opts?: { agentId?: string }): LoadSessionEntryMockResult => ({ + cfg: {}, + canonicalKey: sessionKey, + entry: {}, + }), +); let registeredListener: ((evt: unknown) => void) | undefined; const embeddedEventTimestamp = Date.parse("2026-05-09T07:26:00.000Z"); @@ -37,12 +56,22 @@ vi.mock("../cli/deps.js", () => ({ })); vi.mock("../config/sessions.js", () => ({ + clearSessionGoal: (...args: unknown[]) => clearSessionGoalMock(...args), + createSessionGoal: (...args: unknown[]) => createSessionGoalMock(...args), + formatSessionGoalStatus: (goal?: { objective?: string }) => + goal ? `Goal: ${goal.objective ?? ""}` : "No goal for this session.", + getSessionGoal: (...args: unknown[]) => getSessionGoalMock(...args), resolveAgentMainSessionKey: () => "agent:main:main", resolveStorePath: () => "/tmp/openclaw-sessions.json", + updateSessionGoalStatus: (...args: unknown[]) => updateSessionGoalStatusMock(...args), updateSessionStore: (...args: unknown[]) => updateSessionStoreMock(...args), })); vi.mock("../agents/agent-scope.js", () => ({ + resolveDefaultAgentId: (cfg?: { + agents?: { list?: Array<{ id?: string; default?: boolean }> }; + }) => + cfg?.agents?.list?.find((agent) => agent.default)?.id ?? cfg?.agents?.list?.[0]?.id ?? "main", resolveSessionAgentId: () => "main", })); @@ -94,12 +123,11 @@ vi.mock("../gateway/server-methods/chat.js", () => ({ vi.mock("../gateway/session-utils.js", () => ({ listAgentsForGateway: () => [], - listSessionsFromStoreAsync: async () => ({ sessions: [] }), - loadCombinedSessionStoreForGateway: () => ({ - storePath: "/tmp/openclaw-sessions.json", - store: {}, - }), - loadSessionEntry: (sessionKey: string) => loadSessionEntryMock(sessionKey), + listSessionsFromStoreAsync: (...args: unknown[]) => listSessionsFromStoreAsyncMock(...args), + loadCombinedSessionStoreForGateway: (...args: unknown[]) => + loadCombinedSessionStoreForGatewayMock(...args), + loadSessionEntry: (sessionKey: string, opts?: { agentId?: string }) => + loadSessionEntryMock(sessionKey, opts), migrateAndPruneGatewaySessionStoreKey: ({ key }: { key: string }) => ({ primaryKey: key }), readSessionMessagesAsync: async () => [], resolveGatewaySessionStoreTarget: ({ key }: { key: string }) => ({ @@ -165,6 +193,28 @@ describe("EmbeddedTuiBackend", () => { async (_storePath: string, update: (store: Record) => unknown) => await update({}), ); + createSessionGoalMock.mockReset(); + createSessionGoalMock.mockImplementation(async ({ objective }: { objective: string }) => ({ + objective, + tokensUsed: 0, + })); + clearSessionGoalMock.mockReset(); + clearSessionGoalMock.mockResolvedValue(false); + getSessionGoalMock.mockReset(); + getSessionGoalMock.mockResolvedValue({ status: "missing" }); + updateSessionGoalStatusMock.mockReset(); + updateSessionGoalStatusMock.mockImplementation(async ({ status }: { status: string }) => ({ + objective: "ship", + status, + tokensUsed: 0, + })); + listSessionsFromStoreAsyncMock.mockReset(); + listSessionsFromStoreAsyncMock.mockResolvedValue({ sessions: [] }); + loadCombinedSessionStoreForGatewayMock.mockReset(); + loadCombinedSessionStoreForGatewayMock.mockReturnValue({ + storePath: "/tmp/openclaw-sessions.json", + store: {}, + }); applySessionsPatchToStoreMock.mockReset(); applySessionsPatchToStoreMock.mockResolvedValue({ ok: true, entry: {} }); getRuntimeConfigMock.mockReset(); @@ -412,6 +462,74 @@ describe("EmbeddedTuiBackend", () => { expect(loadGatewayModelCatalogMock).toHaveBeenCalledWith({ readOnly: false }); }); + it("scopes local session lists to the selected agent store", async () => { + const { EmbeddedTuiBackend } = await import("./embedded-backend.js"); + const backend = new EmbeddedTuiBackend(); + + await backend.listSessions({ agentId: "work", includeGlobal: true, search: "global" }); + + expect(loadCombinedSessionStoreForGatewayMock).toHaveBeenCalledWith({}, { agentId: "work" }); + expect(listSessionsFromStoreAsyncMock).toHaveBeenCalledWith({ + cfg: {}, + storePath: "/tmp/openclaw-sessions.json", + store: {}, + opts: { agentId: "work", includeGlobal: true, search: "global" }, + }); + }); + + it("creates a local session entry before starting a goal", async () => { + loadSessionEntryMock.mockReturnValueOnce({ + cfg: {}, + canonicalKey: "agent:main:main", + storePath: "/tmp/openclaw-sessions.json", + }); + + const { EmbeddedTuiBackend } = await import("./embedded-backend.js"); + const backend = new EmbeddedTuiBackend(); + + await expect( + backend.runGoalCommand({ + sessionKey: "agent:main:main", + command: "/GOAL start Ship Goal", + }), + ).resolves.toEqual({ text: "Goal started: Ship Goal" }); + expect(createSessionGoalMock).toHaveBeenCalledWith({ + sessionKey: "agent:main:main", + storePath: "/tmp/openclaw-sessions.json", + objective: "Ship Goal", + fallbackEntry: { + sessionId: expect.any(String), + updatedAt: expect.any(Number), + }, + }); + }); + + it("uses the selected agent when running local global goal commands", async () => { + loadSessionEntryMock.mockReturnValueOnce({ + cfg: {}, + canonicalKey: "global", + storePath: "/tmp/openclaw-work-sessions.json", + entry: { sessionId: "session-work", updatedAt: embeddedEventTimestamp }, + }); + + const { EmbeddedTuiBackend } = await import("./embedded-backend.js"); + const backend = new EmbeddedTuiBackend(); + + await expect( + backend.runGoalCommand({ + sessionKey: "global", + agentId: "work", + command: "/goal status", + }), + ).resolves.toEqual({ text: "No goal for this session." }); + + expect(loadSessionEntryMock).toHaveBeenCalledWith("global", { agentId: "work" }); + expect(getSessionGoalMock).toHaveBeenCalledWith({ + sessionKey: "global", + storePath: "/tmp/openclaw-work-sessions.json", + }); + }); + it("loads history thinking defaults from configured replace-mode models", async () => { loadSessionEntryMock.mockReturnValue({ cfg: { @@ -439,6 +557,60 @@ describe("EmbeddedTuiBackend", () => { expect(loadGatewayModelCatalogMock).not.toHaveBeenCalled(); }); + it("loads selected-agent global history from the selected agent store", async () => { + loadSessionEntryMock.mockReturnValue({ + cfg: {}, + canonicalKey: "global", + storePath: "/tmp/openclaw-work-sessions.json", + entry: { sessionId: "session-work-global" }, + }); + + const { EmbeddedTuiBackend } = await import("./embedded-backend.js"); + const backend = new EmbeddedTuiBackend(); + + await expect( + backend.loadHistory({ sessionKey: "global", agentId: "work" }), + ).resolves.toMatchObject({ + sessionKey: "global", + sessionId: "session-work-global", + messages: [], + }); + expect(loadSessionEntryMock).toHaveBeenCalledWith("global", { agentId: "work" }); + }); + + it("passes selected-agent global scope into local chat turns", async () => { + agentCommandFromIngressMock.mockResolvedValueOnce({ + payloads: [{ text: "done" }], + meta: {}, + }); + + const { EmbeddedTuiBackend } = await import("./embedded-backend.js"); + const backend = new EmbeddedTuiBackend(); + backend.start(); + try { + await backend.sendChat({ + sessionKey: "global", + agentId: "work", + message: "hello", + runId: "run-global-work", + }); + await flushMicrotasks(); + + expect(loadSessionEntryMock).toHaveBeenCalledWith("global", { agentId: "work" }); + expect(agentCommandFromIngressMock).toHaveBeenCalledWith( + expect.objectContaining({ + sessionKey: "global", + agentId: "work", + message: expect.stringContaining("hello"), + }), + expect.anything(), + expect.anything(), + ); + } finally { + await backend.stop(); + } + }); + it("waits for local post-turn maintenance before emitting chat final", async () => { const { EmbeddedTuiBackend } = await import("./embedded-backend.js"); const pending = deferred<{ @@ -906,6 +1078,173 @@ describe("EmbeddedTuiBackend", () => { await flushMicrotasks(); }); + it("runs selected-agent global sends independently across agents", async () => { + const { EmbeddedTuiBackend } = await import("./embedded-backend.js"); + const first = deferred<{ + payloads: Array<{ text: string }>; + meta: Record; + }>(); + const second = deferred<{ + payloads: Array<{ text: string }>; + meta: Record; + }>(); + agentCommandFromIngressMock + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise); + + const backend = new EmbeddedTuiBackend(); + backend.start(); + await backend.sendChat({ + sessionKey: "global", + agentId: "main", + message: "first", + runId: "run-local-main-global", + }); + await backend.sendChat({ + sessionKey: "global", + agentId: "work", + message: "second", + runId: "run-local-work-global", + }); + + expect(agentCommandFromIngressMock).toHaveBeenCalledTimes(2); + + first.resolve({ payloads: [{ text: "main done" }], meta: {} }); + second.resolve({ payloads: [{ text: "work done" }], meta: {} }); + await flushMicrotasks(); + }); + + it("does not stop another agent's selected global local run", async () => { + const { EmbeddedTuiBackend } = await import("./embedded-backend.js"); + const first = deferred<{ + payloads: Array<{ text: string }>; + meta: Record; + }>(); + const stop = deferred<{ + payloads: Array<{ text: string }>; + meta: Record; + }>(); + const firstAbortListener = vi.fn(() => { + first.resolve({ payloads: [{ text: "main aborted" }], meta: {} }); + }); + agentCommandFromIngressMock + .mockImplementationOnce((opts: { abortSignal?: AbortSignal }) => { + opts.abortSignal?.addEventListener("abort", firstAbortListener); + return first.promise; + }) + .mockReturnValueOnce(stop.promise); + + const backend = new EmbeddedTuiBackend(); + backend.start(); + await backend.sendChat({ + sessionKey: "global", + agentId: "main", + message: "first", + runId: "run-local-main-global-stop", + }); + await backend.sendChat({ + sessionKey: "global", + agentId: "work", + message: "/stop", + runId: "run-local-work-global-stop", + }); + + expect(firstAbortListener).not.toHaveBeenCalled(); + expect(agentCommandFromIngressMock).toHaveBeenCalledTimes(2); + + first.resolve({ payloads: [{ text: "main done" }], meta: {} }); + stop.resolve({ payloads: [{ text: "work stop" }], meta: {} }); + await flushMicrotasks(); + }); + + it("does not abort selected-global run ids across default-agent boundaries", async () => { + const { EmbeddedTuiBackend } = await import("./embedded-backend.js"); + getRuntimeConfigMock.mockReturnValue({ + agents: { list: [{ id: "main", default: true }, { id: "work" }] }, + }); + const defaultRun = deferred<{ + payloads: Array<{ text: string }>; + meta: Record; + }>(); + const workRun = deferred<{ + payloads: Array<{ text: string }>; + meta: Record; + }>(); + const defaultAbortListener = vi.fn(() => { + defaultRun.resolve({ payloads: [{ text: "default aborted" }], meta: {} }); + }); + const workAbortListener = vi.fn(() => { + workRun.resolve({ payloads: [{ text: "work aborted" }], meta: {} }); + }); + agentCommandFromIngressMock + .mockImplementationOnce((opts: { abortSignal?: AbortSignal }) => { + opts.abortSignal?.addEventListener("abort", defaultAbortListener); + return defaultRun.promise; + }) + .mockImplementationOnce((opts: { abortSignal?: AbortSignal }) => { + opts.abortSignal?.addEventListener("abort", workAbortListener); + return workRun.promise; + }); + + const backend = new EmbeddedTuiBackend(); + backend.start(); + await backend.sendChat({ + sessionKey: "global", + message: "default", + runId: "run-local-default-global", + }); + await backend.sendChat({ + sessionKey: "global", + agentId: "work", + message: "work", + runId: "run-local-work-global", + }); + + await expect( + backend.abortChat({ + sessionKey: "global", + agentId: "work", + runId: "run-local-default-global", + }), + ).resolves.toEqual({ ok: true, aborted: false }); + await expect( + backend.abortChat({ + sessionKey: "global", + runId: "run-local-work-global", + }), + ).resolves.toEqual({ ok: true, aborted: false }); + + expect(defaultAbortListener).not.toHaveBeenCalled(); + expect(workAbortListener).not.toHaveBeenCalled(); + + defaultRun.resolve({ payloads: [{ text: "default done" }], meta: {} }); + workRun.resolve({ payloads: [{ text: "work done" }], meta: {} }); + await flushMicrotasks(); + }); + + it("scopes selected global patches to the selected agent", async () => { + const { EmbeddedTuiBackend } = await import("./embedded-backend.js"); + const backend = new EmbeddedTuiBackend(); + + await backend.patchSession({ + key: "global", + agentId: "work", + fastMode: true, + }); + + expect(applySessionsPatchToStoreMock).toHaveBeenCalledWith( + expect.objectContaining({ + storeKey: "global", + agentId: "work", + patch: expect.objectContaining({ + key: "global", + agentId: "work", + fastMode: true, + }), + }), + ); + }); + it("fails a queued local send when the previous finishing run does not settle", async () => { const previous = process.env.OPENCLAW_TUI_LOCAL_RUN_SHUTDOWN_GRACE_MS; process.env.OPENCLAW_TUI_LOCAL_RUN_SHUTDOWN_GRACE_MS = "5"; diff --git a/src/tui/embedded-backend.ts b/src/tui/embedded-backend.ts index 75160590eff4..2ae2c60eac52 100644 --- a/src/tui/embedded-backend.ts +++ b/src/tui/embedded-backend.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; import type { SessionsPatchResult } from "../../packages/gateway-protocol/src/index.js"; import { agentCommandFromIngress } from "../agents/agent-command.js"; -import { resolveSessionAgentId } from "../agents/agent-scope.js"; +import { resolveDefaultAgentId, resolveSessionAgentId } from "../agents/agent-scope.js"; import { ensureContextWindowCacheLoaded } from "../agents/context.js"; import { DEFAULT_PROVIDER } from "../agents/defaults.js"; import { @@ -9,9 +9,17 @@ import { buildConfiguredModelCatalog, resolveThinkingDefault, } from "../agents/model-selection.js"; +import { parseGoalCommand } from "../auto-reply/reply/commands-goal.js"; import { createDefaultDeps } from "../cli/deps.js"; import { getRuntimeConfig } from "../config/config.js"; -import { updateSessionStore } from "../config/sessions.js"; +import { + clearSessionGoal, + createSessionGoal, + formatSessionGoalStatus, + getSessionGoal, + updateSessionGoalStatus, + updateSessionStore, +} from "../config/sessions.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { isChatStopCommandText } from "../gateway/chat-abort.js"; import { @@ -52,6 +60,7 @@ import { import { applySessionsPatchToStore } from "../gateway/sessions-patch.js"; import { type AgentEventPayload, onAgentEvent } from "../infra/agent-events.js"; import { setEmbeddedMode } from "../infra/embedded-mode.js"; +import { normalizeAgentId } from "../routing/session-key.js"; import { defaultRuntime } from "../runtime.js"; import { INTERNAL_MESSAGE_CHANNEL } from "../utils/message-channel.js"; import { resolveLocalRunShutdownGraceMs } from "./local-run-shutdown.js"; @@ -66,6 +75,7 @@ import type { type LocalRunState = { sessionKey: string; + agentId?: string; controller: AbortController; buffer: string; lastBroadcastText?: string; @@ -325,18 +335,23 @@ export class EmbeddedTuiBackend implements TuiBackend { async sendChat(opts: ChatSendOptions): Promise<{ runId: string }> { const runId = opts.runId ?? randomUUID(); const question = resolveBtwQuestion(opts.message); - const abortableSessionRun = this.hasAbortableSessionRun(opts.sessionKey); + const runScope = { + sessionKey: opts.sessionKey, + agentId: opts.agentId, + }; + const abortableSessionRun = this.hasAbortableSessionRun(runScope); const stopCommand = abortableSessionRun && isChatStopCommandText(opts.message); const queuedAfter = - question || stopCommand ? undefined : this.findQueuedSessionRunPromise(opts.sessionKey); + question || stopCommand ? undefined : this.findQueuedSessionRunPromise(runScope); if (stopCommand) { - this.abortSessionRuns(opts.sessionKey); + this.abortSessionRuns(runScope); return { runId }; } const controller = new AbortController(); const queuedRunReadiness = createQueuedRunReadiness(); this.runs.set(runId, { sessionKey: opts.sessionKey, + agentId: opts.agentId, controller, buffer: "", isBtw: Boolean(question), @@ -352,6 +367,7 @@ export class EmbeddedTuiBackend implements TuiBackend { const runPromise = this.runTurn({ runId, sessionKey: opts.sessionKey, + agentId: opts.agentId, message: opts.message, thinking: opts.thinking, deliver: opts.deliver, @@ -367,11 +383,19 @@ export class EmbeddedTuiBackend implements TuiBackend { return { runId }; } - async abortChat(opts: { sessionKey: string; runId: string }) { + async abortChat(opts: { sessionKey: string; agentId?: string; runId: string }) { const run = this.runs.get(opts.runId); if (!run || run.sessionKey !== opts.sessionKey) { return { ok: true, aborted: false }; } + if (opts.sessionKey === "global") { + const defaultAgentId = resolveDefaultAgentId(getRuntimeConfig()); + const requestedAgentId = opts.agentId ? normalizeAgentId(opts.agentId) : defaultAgentId; + const runAgentId = run.agentId ? normalizeAgentId(run.agentId) : defaultAgentId; + if (runAgentId !== requestedAgentId) { + return { ok: true, aborted: false }; + } + } if (!this.isAbortableRun(opts.runId, run)) { return { ok: true, aborted: false }; } @@ -379,10 +403,15 @@ export class EmbeddedTuiBackend implements TuiBackend { return { ok: true, aborted: true }; } - async loadHistory(opts: { sessionKey: string; limit?: number }) { - const { cfg, storePath, entry } = loadSessionEntry(opts.sessionKey); + async loadHistory(opts: { sessionKey: string; agentId?: string; limit?: number }) { + const loadOptions = opts.agentId ? { agentId: opts.agentId } : undefined; + const { cfg, storePath, entry } = loadSessionEntry(opts.sessionKey, loadOptions); const sessionId = entry?.sessionId; - const sessionAgentId = resolveSessionAgentId({ sessionKey: opts.sessionKey, config: cfg }); + const sessionAgentId = resolveSessionAgentId({ + sessionKey: opts.sessionKey, + config: cfg, + agentId: opts.agentId, + }); const resolvedSessionModel = resolveSessionModelRef(cfg, entry, sessionAgentId); const max = Math.min(1000, typeof opts.limit === "number" ? opts.limit : 200); const maxHistoryBytes = getMaxChatHistoryMessagesBytes(); @@ -438,7 +467,9 @@ export class EmbeddedTuiBackend implements TuiBackend { async listSessions(opts?: Parameters[0]): Promise { const cfg = getRuntimeConfig(); - const { storePath, store } = loadCombinedSessionStoreForGateway(cfg); + const { storePath, store } = loadCombinedSessionStoreForGateway(cfg, { + agentId: opts?.agentId, + }); return (await listSessionsFromStoreAsync({ cfg, storePath, @@ -455,17 +486,23 @@ export class EmbeddedTuiBackend implements TuiBackend { opts: Parameters[0], ): Promise { const cfg = getRuntimeConfig(); - const target = resolveGatewaySessionStoreTarget({ cfg, key: opts.key }); + const target = resolveGatewaySessionStoreTarget({ + cfg, + key: opts.key, + agentId: opts.agentId, + }); const applied = await updateSessionStore(target.storePath, async (store) => { const { primaryKey } = migrateAndPruneGatewaySessionStoreKey({ cfg, key: opts.key, store, + agentId: opts.agentId, }); return await applySessionsPatchToStore({ cfg, store, storeKey: primaryKey, + agentId: opts.agentId, patch: opts, loadGatewayModelCatalog: () => loadEmbeddedTuiModelCatalog(cfg), }); @@ -477,6 +514,7 @@ export class EmbeddedTuiBackend implements TuiBackend { const agentId = resolveSessionAgentId({ sessionKey: target.canonicalKey ?? opts.key, config: cfg, + agentId: opts.agentId, }); const resolved = resolveSessionModelRef(cfg, applied.entry, agentId); return { @@ -491,9 +529,10 @@ export class EmbeddedTuiBackend implements TuiBackend { }; } - async resetSession(key: string, reason?: "new" | "reset") { + async resetSession(key: string, reason?: "new" | "reset", opts?: { agentId?: string }) { const result = await performGatewaySessionReset({ key, + ...(opts?.agentId ? { agentId: opts.agentId } : {}), reason: reason === "new" ? "new" : "reset", commandSource: "tui:embedded", }); @@ -525,10 +564,92 @@ export class EmbeddedTuiBackend implements TuiBackend { })); } - private findQueuedSessionRunPromise(sessionKey: string): QueuedSessionRun | undefined { + async runGoalCommand(opts: Parameters>[0]) { + const loadOptions = opts.agentId ? { agentId: opts.agentId } : undefined; + const { canonicalKey, storePath, entry } = loadSessionEntry(opts.sessionKey, loadOptions); + const sessionKey = canonicalKey ?? opts.sessionKey; + const parsed = parseGoalCommand(opts.command.trim()); + if (!parsed) { + throw new Error("invalid goal command"); + } + + switch (parsed.action) { + case "status": { + const snapshot = await getSessionGoal({ sessionKey, storePath }); + return { text: formatSessionGoalStatus(snapshot.goal) }; + } + case "start": + case "set": + case "create": { + const objective = parsed.text.trim(); + if (!objective) { + return { text: "Usage: /goal start " }; + } + const fallbackEntry = entry ?? { sessionId: randomUUID(), updatedAt: Date.now() }; + const goal = await createSessionGoal({ + sessionKey, + storePath, + objective, + fallbackEntry, + }); + return { text: `Goal started: ${goal.objective}` }; + } + case "pause": { + const goal = await updateSessionGoalStatus({ + sessionKey, + storePath, + status: "paused", + ...(parsed.text ? { note: parsed.text } : {}), + }); + return { text: `Goal paused: ${goal.objective}` }; + } + case "resume": { + const goal = await updateSessionGoalStatus({ + sessionKey, + storePath, + status: "active", + ...(parsed.text ? { note: parsed.text } : {}), + }); + return { text: `Goal resumed: ${goal.objective}` }; + } + case "complete": + case "done": { + const goal = await updateSessionGoalStatus({ + sessionKey, + storePath, + status: "complete", + ...(parsed.text ? { note: parsed.text } : {}), + }); + return { text: `Goal complete: ${goal.objective}\nTokens used: ${goal.tokensUsed}` }; + } + case "block": + case "blocked": { + const goal = await updateSessionGoalStatus({ + sessionKey, + storePath, + status: "blocked", + ...(parsed.text ? { note: parsed.text } : {}), + }); + return { text: `Goal blocked: ${goal.objective}` }; + } + case "clear": { + const removed = await clearSessionGoal({ sessionKey, storePath }); + return { text: removed ? "Goal cleared." : "No goal to clear." }; + } + default: + return { + text: "Usage: /goal [status] | /goal start | /goal pause|resume|complete|block|clear", + }; + } + } + + private findQueuedSessionRunPromise(params: { + sessionKey: string; + agentId?: string; + }): QueuedSessionRun | undefined { let queuedAfter: QueuedSessionRun | undefined; for (const [runId, run] of this.runs) { - if (run.sessionKey === sessionKey && !run.isBtw) { + if (this.isSameRunScope(run, params) && !run.isBtw) { const promise = this.runPromises.get(runId); if (promise) { queuedAfter = { run, promise }; @@ -538,23 +659,33 @@ export class EmbeddedTuiBackend implements TuiBackend { return queuedAfter; } - private abortSessionRuns(sessionKey: string) { + private abortSessionRuns(params: { sessionKey: string; agentId?: string }) { for (const [runId, run] of this.runs) { - if (run.sessionKey === sessionKey && !run.isBtw && this.isAbortableRun(runId, run)) { + if (this.isSameRunScope(run, params) && !run.isBtw && this.isAbortableRun(runId, run)) { run.controller.abort(); } } } - private hasAbortableSessionRun(sessionKey: string): boolean { + private hasAbortableSessionRun(params: { sessionKey: string; agentId?: string }): boolean { for (const [runId, run] of this.runs) { - if (run.sessionKey === sessionKey && !run.isBtw && this.isAbortableRun(runId, run)) { + if (this.isSameRunScope(run, params) && !run.isBtw && this.isAbortableRun(runId, run)) { return true; } } return false; } + private isSameRunScope(run: LocalRunState, params: { sessionKey: string; agentId?: string }) { + if (run.sessionKey !== params.sessionKey) { + return false; + } + if (params.sessionKey !== "global") { + return true; + } + return run.agentId === params.agentId; + } + private isAbortableRun(runId: string, run: LocalRunState): boolean { return !run.lifecycleEnded || this.runPromises.has(runId); } @@ -799,6 +930,7 @@ export class EmbeddedTuiBackend implements TuiBackend { private async runTurn(params: { runId: string; sessionKey: string; + agentId?: string; message: string; thinking?: string; deliver?: boolean; @@ -830,11 +962,13 @@ export class EmbeddedTuiBackend implements TuiBackend { return; } } - const { cfg, canonicalKey, entry } = loadSessionEntry(params.sessionKey); + const loadOptions = params.agentId ? { agentId: params.agentId } : undefined; + const { cfg, canonicalKey, entry } = loadSessionEntry(params.sessionKey, loadOptions); const result = await agentCommandFromIngress( { message: injectTimestamp(params.message, timestampOptsFromConfig(cfg)), sessionKey: canonicalKey, + ...(params.agentId ? { agentId: params.agentId } : {}), ...(entry?.sessionId ? { sessionId: entry.sessionId } : {}), thinking: params.thinking, deliver: params.deliver, diff --git a/src/tui/gateway-chat.test.ts b/src/tui/gateway-chat.test.ts index 13885a50f5bb..d19578e8ca0e 100644 --- a/src/tui/gateway-chat.test.ts +++ b/src/tui/gateway-chat.test.ts @@ -615,6 +615,45 @@ describe("GatewayChatClient", () => { expect(request).toHaveBeenCalledTimes(2); }); + it("passes selected-agent global scope through chat methods", async () => { + const client = new GatewayChatClient({ + url: "ws://127.0.0.1:18789", + token: "test-token", + allowInsecureLocalOperatorUi: true, + }); + const request = vi.fn().mockResolvedValue({ messages: [] }); + (client as unknown as { client: { request: typeof request } }).client.request = request; + + await client.sendChat({ + sessionKey: "global", + agentId: "work", + message: "hello", + runId: "run-global-work", + }); + await client.loadHistory({ sessionKey: "global", agentId: "work", limit: 50 }); + await client.abortChat({ sessionKey: "global", agentId: "work", runId: "run-global-work" }); + + expect(request).toHaveBeenNthCalledWith(1, "chat.send", { + sessionKey: "global", + agentId: "work", + message: "hello", + thinking: undefined, + deliver: undefined, + timeoutMs: undefined, + idempotencyKey: "run-global-work", + }); + expect(request).toHaveBeenNthCalledWith(2, "chat.history", { + sessionKey: "global", + agentId: "work", + limit: 50, + }); + expect(request).toHaveBeenNthCalledWith(3, "chat.abort", { + sessionKey: "global", + agentId: "work", + runId: "run-global-work", + }); + }); + it("lists gateway commands through commands.list", async () => { const client = new GatewayChatClient({ url: "ws://127.0.0.1:18789", diff --git a/src/tui/gateway-chat.ts b/src/tui/gateway-chat.ts index e17fff0252ad..f7c0057896a2 100644 --- a/src/tui/gateway-chat.ts +++ b/src/tui/gateway-chat.ts @@ -190,6 +190,7 @@ export class GatewayChatClient implements TuiBackend { const runId = opts.runId ?? randomUUID(); await this.client.request("chat.send", { sessionKey: opts.sessionKey, + ...(opts.agentId ? { agentId: opts.agentId } : {}), ...(opts.sessionId ? { sessionId: opts.sessionId } : {}), message: opts.message, thinking: opts.thinking, @@ -200,19 +201,21 @@ export class GatewayChatClient implements TuiBackend { return { runId }; } - async abortChat(opts: { sessionKey: string; runId: string }) { + async abortChat(opts: { sessionKey: string; agentId?: string; runId: string }) { return await this.client.request<{ ok: boolean; aborted: boolean }>("chat.abort", { sessionKey: opts.sessionKey, + ...(opts.agentId ? { agentId: opts.agentId } : {}), runId: opts.runId, }); } - async loadHistory(opts: { sessionKey: string; limit?: number }) { + async loadHistory(opts: { sessionKey: string; agentId?: string; limit?: number }) { const startedAt = Date.now(); for (;;) { try { return await this.client.request("chat.history", { sessionKey: opts.sessionKey, + ...(opts.agentId ? { agentId: opts.agentId } : {}), limit: opts.limit, }); } catch (err) { @@ -239,9 +242,10 @@ export class GatewayChatClient implements TuiBackend { return await this.client.request("sessions.patch", opts); } - async resetSession(key: string, reason?: "new" | "reset") { + async resetSession(key: string, reason?: "new" | "reset", opts?: { agentId?: string }) { return await this.client.request("sessions.reset", { key, + ...(opts?.agentId ? { agentId: opts.agentId } : {}), ...(reason ? { reason } : {}), }); } diff --git a/src/tui/tui-backend.ts b/src/tui/tui-backend.ts index dff41c86c3e4..c340e43536d0 100644 --- a/src/tui/tui-backend.ts +++ b/src/tui/tui-backend.ts @@ -9,6 +9,7 @@ import type { ResponseUsageMode, SessionInfo, SessionScope } from "./tui-types.j export type ChatSendOptions = { sessionKey: string; + agentId?: string; sessionId?: string | null; message: string; thinking?: string; @@ -17,6 +18,12 @@ export type ChatSendOptions = { runId?: string; }; +export type TuiGoalCommandOptions = { + sessionKey: string; + agentId?: string; + command: string; +}; + export type TuiEvent = { event: string; payload?: unknown; @@ -49,6 +56,7 @@ export type TuiSessionList = { | "inputTokens" | "outputTokens" | "totalTokens" + | "goal" | "modelProvider" | "displayName" > & { @@ -112,14 +120,20 @@ export type TuiBackend = { sendChat: (opts: ChatSendOptions) => Promise<{ runId: string }>; abortChat: (opts: { sessionKey: string; + agentId?: string; runId: string; }) => Promise<{ ok: boolean; aborted: boolean }>; - loadHistory: (opts: { sessionKey: string; limit?: number }) => Promise; + loadHistory: (opts: { sessionKey: string; agentId?: string; limit?: number }) => Promise; listSessions: (opts?: SessionsListParams) => Promise; listAgents: () => Promise; patchSession: (opts: SessionsPatchParams) => Promise; - resetSession: (key: string, reason?: "new" | "reset") => Promise; + resetSession: ( + key: string, + reason?: "new" | "reset", + opts?: { agentId?: string }, + ) => Promise; getGatewayStatus: () => Promise; listModels: () => Promise; listCommands?: (opts?: CommandsListParams) => Promise; + runGoalCommand?: (opts: TuiGoalCommandOptions) => Promise<{ text: string }>; }; diff --git a/src/tui/tui-command-handlers.test.ts b/src/tui/tui-command-handlers.test.ts index 9cf752b94f8c..99dc0c9193a6 100644 --- a/src/tui/tui-command-handlers.test.ts +++ b/src/tui/tui-command-handlers.test.ts @@ -22,15 +22,23 @@ async function flushAsyncSelect() { function expectSendChatFields( sendChat: ReturnType, - expected: { message: string; sessionId?: string; sessionKey?: string }, + expected: { message: string; agentId?: string; sessionId?: string; sessionKey?: string }, ) { const calls = sendChat.mock.calls; const call = calls[calls.length - 1]; if (!call) { throw new Error("expected gateway sendChat call"); } - const payload = call[0] as { message?: unknown; sessionId?: unknown; sessionKey?: unknown }; + const payload = call[0] as { + message?: unknown; + agentId?: unknown; + sessionId?: unknown; + sessionKey?: unknown; + }; expect(payload.message).toBe(expected.message); + if (expected.agentId !== undefined) { + expect(payload.agentId).toBe(expected.agentId); + } if (expected.sessionId !== undefined) { expect(payload.sessionId).toBe(expected.sessionId); } @@ -56,6 +64,7 @@ function createHarness(params?: { listModels?: ReturnType; patchSession?: ReturnType; resetSession?: ReturnType; + runGoalCommand?: ReturnType; runAuthFlow?: RunAuthFlow; setSession?: SetSessionMock; loadHistory?: LoadHistoryMock; @@ -69,6 +78,8 @@ function createHarness(params?: { activityStatus?: string; opts?: { local?: boolean }; currentSessionId?: string | null; + currentAgentId?: string; + currentSessionKey?: string; abortActive?: AbortActiveMock; }) { const sendChat = params?.sendChat ?? vi.fn().mockResolvedValue({ runId: "r1" }); @@ -77,6 +88,7 @@ function createHarness(params?: { const listModels = params?.listModels ?? vi.fn().mockResolvedValue([]); const patchSession = params?.patchSession ?? vi.fn().mockResolvedValue({}); const resetSession = params?.resetSession ?? vi.fn().mockResolvedValue({ ok: true }); + const runGoalCommand = params?.runGoalCommand ?? vi.fn().mockResolvedValue({ text: "Goal" }); const setSession = params?.setSession ?? (vi.fn().mockResolvedValue(undefined) as SetSessionMock); const addUser = vi.fn(); const addSystem = vi.fn(); @@ -100,8 +112,8 @@ function createHarness(params?: { ? (vi.fn().mockResolvedValue({ exitCode: 0, signal: null }) as unknown as RunAuthFlow) : undefined); const state = { - currentAgentId: "main", - currentSessionKey: "agent:main:main", + currentAgentId: params?.currentAgentId ?? "main", + currentSessionKey: params?.currentSessionKey ?? "agent:main:main", currentSessionId: params?.currentSessionId ?? null, activeChatRunId: params?.activeChatRunId ?? null, pendingOptimisticUserMessage: params?.pendingOptimisticUserMessage ?? false, @@ -119,6 +131,7 @@ function createHarness(params?: { listModels, patchSession, resetSession, + runGoalCommand, } as never, chatLog: { addUser, addSystem, reserveAssistantSlot } as never, tui: { requestRender } as never, @@ -154,6 +167,7 @@ function createHarness(params?: { closeOverlay, patchSession, resetSession, + runGoalCommand, setSession, addUser, addSystem, @@ -254,6 +268,70 @@ describe("tui command handlers", () => { }); }); + it("runs goal commands locally instead of sending them to the model", async () => { + const runGoalCommand = vi.fn().mockResolvedValue({ text: "Goal started: ship" }); + const { handleCommand, sendChat, addSystem, refreshSessionInfo } = createHarness({ + opts: { local: true }, + runGoalCommand, + }); + + await handleCommand("/goal start ship"); + + expect(runGoalCommand).toHaveBeenCalledWith({ + sessionKey: "agent:main:main", + agentId: "main", + command: "/goal start ship", + }); + expect(sendChat).not.toHaveBeenCalled(); + expect(addSystem).toHaveBeenCalledWith("Goal started: ship"); + expect(refreshSessionInfo).toHaveBeenCalled(); + }); + + it("passes the selected agent for local global goal commands", async () => { + const runGoalCommand = vi.fn().mockResolvedValue({ text: "Goal started: ship" }); + const { handleCommand } = createHarness({ + opts: { local: true }, + currentAgentId: "work", + currentSessionKey: "global", + runGoalCommand, + }); + + await handleCommand("/goal start ship"); + + expect(runGoalCommand).toHaveBeenCalledWith({ + sessionKey: "global", + agentId: "work", + command: "/goal start ship", + }); + }); + + it("passes the selected agent when sending global chat", async () => { + const { handleCommand, sendChat } = createHarness({ + currentAgentId: "work", + currentSessionKey: "global", + }); + + await handleCommand("hello"); + + expectSendChatFields(sendChat, { + sessionKey: "global", + agentId: "work", + message: "hello", + }); + }); + + it("forwards goal commands to the gateway outside local mode", async () => { + const { handleCommand, sendChat, runGoalCommand } = createHarness(); + + await handleCommand("/goal status"); + + expect(runGoalCommand).not.toHaveBeenCalled(); + expectSendChatFields(sendChat, { + sessionKey: "agent:main:main", + message: "/goal status", + }); + }); + it("opens a context mode selector for /context without sending immediately", async () => { const { handleCommand, sendChat, openOverlay } = createHarness(); @@ -456,10 +534,38 @@ describe("tui command handlers", () => { expect(uuidParts.every((part) => /^[0-9a-f]+$/.test(part))).toBe(true); // /reset still resets the shared session expect(resetSession).toHaveBeenCalledTimes(1); - expect(resetSession).toHaveBeenCalledWith("agent:main:main", "reset"); + expect(resetSession).toHaveBeenCalledWith("agent:main:main", "reset", undefined); expect(loadHistory).toHaveBeenCalledTimes(1); // /reset calls loadHistory directly; /new does so indirectly via setSession }); + it("scopes /reset for the selected global agent", async () => { + const { handleCommand, resetSession } = createHarness({ + currentSessionKey: "global", + currentAgentId: "work", + }); + + await handleCommand("/reset"); + + expect(resetSession).toHaveBeenCalledWith("global", "reset", { agentId: "work" }); + }); + + it("scopes selected global session patches to the selected agent", async () => { + const patchSession = vi.fn().mockResolvedValue({ fastMode: true }); + const { handleCommand } = createHarness({ + currentSessionKey: "global", + currentAgentId: "work", + patchSession, + }); + + await handleCommand("/fast on"); + + expect(patchSession).toHaveBeenCalledWith({ + key: "global", + agentId: "work", + fastMode: true, + }); + }); + it("reports send failures and marks activity status as error", async () => { const setActivityStatus = vi.fn(); const { handleCommand, addSystem, state } = createHarness({ diff --git a/src/tui/tui-command-handlers.ts b/src/tui/tui-command-handlers.ts index c4ab88a63b6a..805c8485e4c0 100644 --- a/src/tui/tui-command-handlers.ts +++ b/src/tui/tui-command-handlers.ts @@ -108,6 +108,11 @@ export function createCommandHandlers(context: CommandHandlerContext) { const hasTrackedAbortTarget = () => Boolean(state.activeChatRunId || state.pendingChatRunId || state.pendingOptimisticUserMessage); + const currentSessionPatchTarget = () => ({ + key: state.currentSessionKey, + ...(state.currentSessionKey === "global" ? { agentId: state.currentAgentId } : {}), + }); + const openSelector = ( selector: { onSelect?: (item: SelectItem) => void; @@ -146,7 +151,7 @@ export function createCommandHandlers(context: CommandHandlerContext) { openSelector(selector, async (value) => { try { const result = await client.patchSession({ - key: state.currentSessionKey, + ...currentSessionPatchTarget(), model: value, }); chatLog.addSystem(`model set to ${value}`); @@ -381,6 +386,23 @@ export function createCommandHandlers(context: CommandHandlerContext) { await sendMessage(raw); } break; + case "goal": + if (opts.local === true && client.runGoalCommand) { + try { + const result = await client.runGoalCommand({ + sessionKey: state.currentSessionKey, + agentId: state.currentAgentId, + command: raw, + }); + chatLog.addSystem(result.text); + await refreshSessionInfo(); + } catch (err) { + chatLog.addSystem(`goal failed: ${sanitizeRenderableText(String(err))}`); + } + } else { + await sendMessage(raw); + } + break; case "crestodian": chatLog.addSystem( args ? `returning to Crestodian with request: ${args}` : "returning to Crestodian", @@ -406,7 +428,7 @@ export function createCommandHandlers(context: CommandHandlerContext) { } else { try { const result = await client.patchSession({ - key: state.currentSessionKey, + ...currentSessionPatchTarget(), model: args, }); chatLog.addSystem(`model set to ${args}`); @@ -430,7 +452,7 @@ export function createCommandHandlers(context: CommandHandlerContext) { } try { const result = await client.patchSession({ - key: state.currentSessionKey, + ...currentSessionPatchTarget(), thinkingLevel: args, }); chatLog.addSystem(`thinking set to ${args}`); @@ -447,7 +469,7 @@ export function createCommandHandlers(context: CommandHandlerContext) { } try { const result = await client.patchSession({ - key: state.currentSessionKey, + ...currentSessionPatchTarget(), verboseLevel: args, }); chatLog.addSystem(`verbose set to ${args}`); @@ -464,7 +486,7 @@ export function createCommandHandlers(context: CommandHandlerContext) { } try { const result = await client.patchSession({ - key: state.currentSessionKey, + ...currentSessionPatchTarget(), traceLevel: args, }); chatLog.addSystem(`trace set to ${args}`); @@ -485,7 +507,7 @@ export function createCommandHandlers(context: CommandHandlerContext) { } try { const result = await client.patchSession({ - key: state.currentSessionKey, + ...currentSessionPatchTarget(), fastMode: args === "on", }); chatLog.addSystem(`fast mode ${args === "on" ? "enabled" : "disabled"}`); @@ -502,7 +524,7 @@ export function createCommandHandlers(context: CommandHandlerContext) { } try { const result = await client.patchSession({ - key: state.currentSessionKey, + ...currentSessionPatchTarget(), reasoningLevel: args, }); chatLog.addSystem(`reasoning set to ${args}`); @@ -524,7 +546,7 @@ export function createCommandHandlers(context: CommandHandlerContext) { normalized ?? (current === "off" ? "tokens" : current === "tokens" ? "full" : "off"); try { const result = await client.patchSession({ - key: state.currentSessionKey, + ...currentSessionPatchTarget(), responseUsage: next === "off" ? null : next, }); chatLog.addSystem(`usage footer: ${next}`); @@ -546,7 +568,7 @@ export function createCommandHandlers(context: CommandHandlerContext) { } try { const result = await client.patchSession({ - key: state.currentSessionKey, + ...currentSessionPatchTarget(), elevatedLevel: args, }); chatLog.addSystem(`elevated set to ${args}`); @@ -568,7 +590,7 @@ export function createCommandHandlers(context: CommandHandlerContext) { } try { const result = await client.patchSession({ - key: state.currentSessionKey, + ...currentSessionPatchTarget(), groupActivation: activation, }); chatLog.addSystem(`activation set to ${activation}`); @@ -605,7 +627,11 @@ export function createCommandHandlers(context: CommandHandlerContext) { state.sessionInfo.totalTokens = null; tui.requestRender(); - await client.resetSession(state.currentSessionKey, name); + await client.resetSession( + state.currentSessionKey, + name, + state.currentSessionKey === "global" ? { agentId: state.currentAgentId } : undefined, + ); chatLog.addSystem(`session ${state.currentSessionKey} reset`); await loadHistory(); } catch (err) { @@ -688,6 +714,7 @@ export function createCommandHandlers(context: CommandHandlerContext) { tui.requestRender(); await client.sendChat({ sessionKey: state.currentSessionKey, + ...(state.currentSessionKey === "global" ? { agentId: state.currentAgentId } : {}), sessionId: state.currentSessionId, message: text, thinking: opts.thinking, diff --git a/src/tui/tui-event-handlers.test.ts b/src/tui/tui-event-handlers.test.ts index 7b7ca3b48b32..732534cf0919 100644 --- a/src/tui/tui-event-handlers.test.ts +++ b/src/tui/tui-event-handlers.test.ts @@ -559,6 +559,54 @@ describe("tui-event-handlers: handleAgentEvent", () => { expect(chatLog.updateAssistant).not.toHaveBeenCalled(); }); + it("ignores selected-global chat events from other agents", () => { + const { chatLog, handleChatEvent } = createHandlersHarness({ + state: { + agentDefaultId: "main", + currentAgentId: "work", + currentSessionKey: "global", + activeChatRunId: null, + }, + }); + + handleChatEvent({ + runId: "run-main-global", + sessionKey: "global", + agentId: "main", + state: "delta", + message: { content: "wrong agent" }, + }); + handleChatEvent({ + runId: "run-legacy-default-global", + sessionKey: "global", + state: "delta", + message: { content: "legacy default" }, + }); + + expect(chatLog.updateAssistant).not.toHaveBeenCalled(); + }); + + it("ignores selected-global BTW events from other agents", () => { + const { btw, handleBtwEvent } = createHandlersHarness({ + state: { + agentDefaultId: "main", + currentAgentId: "work", + currentSessionKey: "global", + }, + }); + + handleBtwEvent({ + kind: "btw", + runId: "btw-main-global", + sessionKey: "global", + agentId: "main", + question: "status?", + text: "wrong agent", + }); + + expect(btw.showResult).not.toHaveBeenCalled(); + }); + it("clears run mapping when the session changes", () => { const { state, chatLog, tui, handleChatEvent, handleAgentEvent } = createHandlersHarness({ state: { activeChatRunId: null }, diff --git a/src/tui/tui-event-handlers.ts b/src/tui/tui-event-handlers.ts index 374a60a333c6..cf30182ef111 100644 --- a/src/tui/tui-event-handlers.ts +++ b/src/tui/tui-event-handlers.ts @@ -431,6 +431,22 @@ export function createEventHandlers(context: EventHandlerContext) { return false; }; + const isMatchingGlobalAgentEvent = ( + sessionKey: string | undefined, + agentId?: string, + ): boolean => { + if (normalizeLowercaseStringOrEmpty(sessionKey) !== "global") { + return true; + } + const selectedAgentId = normalizeLowercaseStringOrEmpty(state.currentAgentId); + const defaultAgentId = normalizeLowercaseStringOrEmpty(state.agentDefaultId); + const eventAgentId = normalizeLowercaseStringOrEmpty(agentId); + if (eventAgentId) { + return eventAgentId === selectedAgentId; + } + return selectedAgentId === defaultAgentId; + }; + const handleChatEvent = (payload: unknown) => { if (!payload || typeof payload !== "object") { return; @@ -440,6 +456,9 @@ export function createEventHandlers(context: EventHandlerContext) { if (!isSameSessionKey(evt.sessionKey, state.currentSessionKey)) { return; } + if (!isMatchingGlobalAgentEvent(evt.sessionKey, evt.agentId)) { + return; + } if (finalizedRuns.has(evt.runId)) { if (evt.state === "delta") { return; @@ -683,6 +702,9 @@ export function createEventHandlers(context: EventHandlerContext) { if (!isSameSessionKey(evt.sessionKey, state.currentSessionKey)) { return; } + if (!isMatchingGlobalAgentEvent(evt.sessionKey, evt.agentId)) { + return; + } if (evt.kind !== "btw") { return; } diff --git a/src/tui/tui-formatters.test.ts b/src/tui/tui-formatters.test.ts index 34c6c67879ed..a6cf749135f2 100644 --- a/src/tui/tui-formatters.test.ts +++ b/src/tui/tui-formatters.test.ts @@ -4,10 +4,46 @@ import { extractContentFromMessage, extractTextFromMessage, extractThinkingFromMessage, + formatGoalFooter, isCommandMessage, sanitizeRenderableText, } from "./tui-formatters.js"; +describe("formatGoalFooter", () => { + it("renders active goal usage", () => { + expect( + formatGoalFooter({ + schemaVersion: 1, + id: "goal-1", + objective: "land PR", + status: "active", + createdAt: 1, + updatedAt: 1, + tokenStart: 0, + tokensUsed: 12_000, + tokenBudget: 30_000, + continuationTurns: 0, + }), + ).toBe("Pursuing goal (12k/30k)"); + }); + + it("renders resumable blocked goals", () => { + expect( + formatGoalFooter({ + schemaVersion: 1, + id: "goal-1", + objective: "land PR", + status: "blocked", + createdAt: 1, + updatedAt: 1, + tokenStart: 0, + tokensUsed: 0, + continuationTurns: 0, + }), + ).toBe("Goal blocked (/goal resume)"); + }); +}); + describe("extractTextFromMessage", () => { it("prefers final_answer text over commentary text for assistant messages", () => { const text = extractTextFromMessage({ diff --git a/src/tui/tui-formatters.ts b/src/tui/tui-formatters.ts index 5b6a871780b6..4fb56cfad30f 100644 --- a/src/tui/tui-formatters.ts +++ b/src/tui/tui-formatters.ts @@ -1,4 +1,5 @@ import { stripLeadingInboundMetadata } from "../auto-reply/reply/strip-inbound-meta.js"; +import type { SessionGoal } from "../config/sessions/types.js"; import { formatRawAssistantErrorForUi } from "../shared/assistant-error-format.js"; import { extractAssistantVisibleText } from "../shared/chat-message-content.js"; import { stripAnsi } from "../terminal/ansi.js"; @@ -441,6 +442,36 @@ export function formatTokens(total?: number | null, context?: number | null) { return `tokens ${totalLabel}/${formatTokenCount(context)}${pct !== null ? ` (${pct}%)` : ""}`; } +function formatGoalUsage(goal: SessionGoal): string | null { + if (goal.tokenBudget === undefined) { + return goal.tokensUsed > 0 ? formatTokenCount(goal.tokensUsed) : null; + } + return `${formatTokenCount(goal.tokensUsed)}/${formatTokenCount(goal.tokenBudget)}`; +} + +export function formatGoalFooter(goal?: SessionGoal): string | null { + if (!goal) { + return null; + } + const usage = formatGoalUsage(goal); + const suffix = usage ? ` (${usage})` : ""; + switch (goal.status) { + case "active": + return `Pursuing goal${suffix}`; + case "paused": + return "Goal paused (/goal resume)"; + case "blocked": + return "Goal blocked (/goal resume)"; + case "usage_limited": + return "Goal hit usage limits (/goal resume)"; + case "budget_limited": + return `Goal unmet${suffix}`; + case "complete": + return `Goal achieved${suffix}`; + } + return null; +} + export function formatContextUsageLine(params: { total?: number | null; context?: number | null; diff --git a/src/tui/tui-session-actions.test.ts b/src/tui/tui-session-actions.test.ts index 006e21282e39..5c46bb86dbfd 100644 --- a/src/tui/tui-session-actions.test.ts +++ b/src/tui/tui-session-actions.test.ts @@ -197,6 +197,100 @@ describe("tui session actions", () => { expect(state.sessionInfo.updatedAt).toBe(200); }); + it("clears the footer goal when the current session has no row yet", async () => { + const listSessions = vi.fn().mockResolvedValue({ + ts: Date.now(), + path: "/tmp/sessions.json", + count: 0, + defaults: {}, + sessions: [], + }); + const state = createBaseState({ + sessionInfo: { + goal: { + schemaVersion: 1, + id: "goal-1", + objective: "old goal", + status: "active", + createdAt: 1, + updatedAt: 1, + tokenStart: 0, + tokenStartFresh: true, + tokensUsed: 0, + continuationTurns: 0, + }, + }, + }); + + const { refreshSessionInfo } = createTestSessionActions({ + client: { listSessions } as unknown as TuiBackend, + state, + }); + + await refreshSessionInfo(); + + expect(state.sessionInfo.goal).toBeUndefined(); + }); + + it("includes the global row when refreshing a global session", async () => { + const listSessions = vi.fn().mockResolvedValue({ + ts: Date.now(), + path: "/tmp/sessions.json", + count: 1, + defaults: {}, + sessions: [{ key: "global", updatedAt: 1 }], + }); + const state = createBaseState({ + currentSessionKey: "global", + sessionScope: "global", + }); + + const { refreshSessionInfo } = createTestSessionActions({ + client: { listSessions } as unknown as TuiBackend, + state, + }); + + await refreshSessionInfo(); + + expect(listSessions).toHaveBeenCalledWith({ + limit: TUI_SESSION_LOOKUP_LIMIT, + search: "global", + includeGlobal: true, + includeUnknown: false, + agentId: "main", + }); + }); + + it("keeps global session info aligned with selected-agent chat history", async () => { + const listSessions = vi.fn().mockResolvedValue({ + ts: Date.now(), + path: "/tmp/sessions.json", + count: 1, + defaults: {}, + sessions: [{ key: "global", updatedAt: 1 }], + }); + const state = createBaseState({ + currentAgentId: "work", + currentSessionKey: "global", + sessionScope: "global", + }); + + const { refreshSessionInfo } = createTestSessionActions({ + client: { listSessions } as unknown as TuiBackend, + state, + }); + + await refreshSessionInfo(); + + expect(listSessions).toHaveBeenCalledWith({ + limit: TUI_SESSION_LOOKUP_LIMIT, + search: "global", + includeGlobal: true, + includeUnknown: false, + agentId: "work", + }); + }); + it("accepts older session snapshots after switching session keys", async () => { const listSessions = vi.fn().mockResolvedValue({ ts: Date.now(), @@ -386,6 +480,28 @@ describe("tui session actions", () => { expect(setActivityStatus).toHaveBeenCalledWith("aborted"); }); + it("passes the selected agent when aborting selected global runs", async () => { + const abortChat = vi.fn().mockResolvedValue({ ok: true, aborted: true }); + const state = createBaseState({ + currentAgentId: "work", + currentSessionKey: "global", + pendingChatRunId: "run-work-global", + }); + + const { abortActive } = createTestSessionActions({ + client: { listSessions: vi.fn(), abortChat } as unknown as TuiBackend, + state, + }); + + await abortActive(); + + expect(abortChat).toHaveBeenCalledWith({ + sessionKey: "global", + agentId: "work", + runId: "run-work-global", + }); + }); + it("coalesces repeated no-active-run abort notices", async () => { const addSystem = vi.fn(); const requestRender = vi.fn(); @@ -575,4 +691,32 @@ describe("tui session actions", () => { expect(state.currentSessionId).toBe("session-main"); expect(rememberSessionKey).toHaveBeenCalledWith("agent:main:main"); }); + + it("loads selected-agent global history with the selected agent id", async () => { + const loadHistory = vi.fn().mockResolvedValue({ + sessionId: "session-work-global", + messages: [], + }); + const state = createBaseState({ + currentAgentId: "work", + currentSessionKey: "global", + }); + + const { loadHistory: runLoadHistory } = createTestSessionActions({ + client: { + listSessions: vi.fn(), + loadHistory, + } as unknown as TuiBackend, + state, + }); + + await runLoadHistory(); + + expect(loadHistory).toHaveBeenCalledWith({ + sessionKey: "global", + agentId: "work", + limit: 200, + }); + expect(state.currentSessionId).toBe("session-work-global"); + }); }); diff --git a/src/tui/tui-session-actions.ts b/src/tui/tui-session-actions.ts index 9ebf6257615a..a3d587525c23 100644 --- a/src/tui/tui-session-actions.ts +++ b/src/tui/tui-session-actions.ts @@ -144,6 +144,7 @@ export function createSessionActions(context: SessionActionContext) { defaults?: SessionInfoDefaults | null; force?: boolean; }) => { + const hasEntryUpdate = "entry" in params; const entry = params.entry ?? undefined; const defaults = params.defaults ?? lastSessionDefaults ?? undefined; const previousDefaults = lastSessionDefaults; @@ -199,6 +200,9 @@ export function createSessionActions(context: SessionActionContext) { if (entry?.totalTokens !== undefined) { next.totalTokens = entry.totalTokens; } + if (hasEntryUpdate) { + next.goal = entry?.goal; + } if (entry?.contextTokens !== undefined || defaults?.contextTokens !== undefined) { next.contextTokens = entry?.contextTokens ?? defaults?.contextTokens ?? state.sessionInfo.contextTokens; @@ -227,7 +231,10 @@ export function createSessionActions(context: SessionActionContext) { const runRefreshSessionInfo = async () => { try { const resolveListAgentId = () => { - if (state.currentSessionKey === "global" || state.currentSessionKey === "unknown") { + if (state.currentSessionKey === "global") { + return state.currentAgentId; + } + if (state.currentSessionKey === "unknown") { return undefined; } const parsed = parseAgentSessionKey(state.currentSessionKey); @@ -237,8 +244,8 @@ export function createSessionActions(context: SessionActionContext) { const result = await client.listSessions({ limit: TUI_SESSION_LOOKUP_LIMIT, search: state.currentSessionKey, - includeGlobal: false, - includeUnknown: false, + includeGlobal: state.currentSessionKey === "global", + includeUnknown: state.currentSessionKey === "unknown", agentId: listAgentId, }); const normalizeMatchKey = (key: string) => parseAgentSessionKey(key)?.rest ?? key; @@ -298,6 +305,7 @@ export function createSessionActions(context: SessionActionContext) { try { const history = await client.loadHistory({ sessionKey: state.currentSessionKey, + ...(state.currentSessionKey === "global" ? { agentId: state.currentAgentId } : {}), limit: opts.historyLimit ?? 200, }); const record = history as { @@ -425,6 +433,7 @@ export function createSessionActions(context: SessionActionContext) { for (const runId of runIds) { await client.abortChat({ sessionKey: state.currentSessionKey, + ...(state.currentSessionKey === "global" ? { agentId: state.currentAgentId } : {}), runId, }); } diff --git a/src/tui/tui-types.ts b/src/tui/tui-types.ts index d68ccd1f2066..9c75cb7d0ec1 100644 --- a/src/tui/tui-types.ts +++ b/src/tui/tui-types.ts @@ -1,3 +1,5 @@ +import type { SessionGoal } from "../config/sessions/types.js"; + export type TuiOptions = { local?: boolean; url?: string; @@ -26,6 +28,7 @@ export type TuiResult = { export type ChatEvent = { runId: string; sessionKey: string; + agentId?: string; state: "delta" | "final" | "aborted" | "error"; message?: unknown; errorMessage?: string; @@ -35,6 +38,7 @@ export type BtwEvent = { kind: "btw"; runId?: string; sessionKey?: string; + agentId?: string; question: string; text: string; isError?: boolean; @@ -63,6 +67,7 @@ export type SessionInfo = { inputTokens?: number | null; outputTokens?: number | null; totalTokens?: number | null; + goal?: SessionGoal; responseUsage?: ResponseUsageMode; updatedAt?: number | null; displayName?: string; diff --git a/src/tui/tui.ts b/src/tui/tui.ts index cb433df9202c..74eb5ed098b9 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -35,7 +35,7 @@ import { editorTheme, theme } from "./theme/theme.js"; import type { TuiBackend } from "./tui-backend.js"; import { createCommandHandlers } from "./tui-command-handlers.js"; import { createEventHandlers } from "./tui-event-handlers.js"; -import { formatTokens } from "./tui-formatters.js"; +import { formatGoalFooter, formatTokens } from "./tui-formatters.js"; import { buildTuiLastSessionScopeKey, readTuiLastSessionKey, @@ -1164,6 +1164,7 @@ export async function runTui(opts: RunTuiOptions): Promise { `agent ${agentLabel}`, `session ${sessionLabel}`, modelLabel, + formatGoalFooter(sessionInfo.goal), think !== "off" ? `think ${think}` : null, fast ? "fast" : null, verbose !== "off" ? `verbose ${verbose}` : null, diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css index c074bc38df19..9ff2f02291a4 100644 --- a/ui/src/styles/chat/layout.css +++ b/ui/src/styles/chat/layout.css @@ -248,6 +248,58 @@ animation: compaction-spin 1s linear infinite; } +.agent-chat__goal { + align-self: center; + display: flex; + align-items: center; + max-width: calc(100% - 20px); + gap: 8px; + margin: 0 auto 8px; + padding: 7px 12px; + color: color-mix(in srgb, var(--text) 82%, var(--muted)); + background: color-mix(in srgb, var(--bg-elevated) 84%, transparent); + border: 1px solid color-mix(in srgb, var(--border) 80%, transparent); + border-radius: var(--radius-sm); + font-size: 12px; + line-height: 1.25; +} + +.agent-chat__goal-label, +.agent-chat__goal-objective { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.agent-chat__goal-label { + flex: 0 1 auto; + font-weight: 700; +} + +.agent-chat__goal-objective { + flex: 1 1 auto; + color: var(--muted); +} + +.agent-chat__goal--active { + color: var(--success); + background: color-mix(in srgb, var(--success) 9%, var(--card)); + border-color: color-mix(in srgb, var(--success) 28%, var(--border)); +} + +.agent-chat__goal--blocked, +.agent-chat__goal--budget_limited, +.agent-chat__goal--usage_limited { + color: var(--warning); + background: color-mix(in srgb, var(--warning) 10%, var(--card)); + border-color: color-mix(in srgb, var(--warning) 30%, var(--border)); +} + +.agent-chat__goal--complete { + color: color-mix(in srgb, var(--success) 72%, var(--text)); +} + /* Chat compose - sticky at bottom */ .chat-compose { position: sticky; diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index 2b35e5bdf03f..980da2e4a2d1 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -2885,6 +2885,14 @@ min-width: 108px; } +.session-status-stack { + display: flex; + flex-direction: column; + align-items: flex-start; + min-width: 0; + gap: 6px; +} + .session-status-badge { display: inline-flex; align-items: center; @@ -2945,6 +2953,56 @@ color: var(--muted); } +.session-goal-chip { + display: inline-grid; + grid-template-columns: minmax(0, auto) minmax(0, 1fr); + align-items: center; + max-width: 116px; + gap: 5px; + box-sizing: border-box; + padding: 3px 7px; + color: color-mix(in srgb, var(--text) 78%, var(--muted)); + background: color-mix(in srgb, var(--bg-elevated) 82%, transparent); + border: 1px solid color-mix(in srgb, var(--border) 82%, transparent); + border-radius: var(--radius-sm); + font-size: 11px; + line-height: 1.2; +} + +.session-goal-chip__label, +.session-goal-chip__objective { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.session-goal-chip__label { + font-weight: 700; +} + +.session-goal-chip__objective { + color: var(--muted); +} + +.session-goal-chip--active { + color: var(--success); + background: color-mix(in srgb, var(--success) 9%, var(--card)); + border-color: color-mix(in srgb, var(--success) 30%, var(--border)); +} + +.session-goal-chip--blocked, +.session-goal-chip--budget_limited, +.session-goal-chip--usage_limited { + color: var(--warning); + background: color-mix(in srgb, var(--warning) 10%, var(--card)); + border-color: color-mix(in srgb, var(--warning) 30%, var(--border)); +} + +.session-goal-chip--complete { + color: color-mix(in srgb, var(--success) 72%, var(--text)); +} + .session-runtime-cell { width: 170px; max-width: 170px; diff --git a/ui/src/styles/components.test.ts b/ui/src/styles/components.test.ts index 1daabfc30ee7..2c85ab1d5733 100644 --- a/ui/src/styles/components.test.ts +++ b/ui/src/styles/components.test.ts @@ -93,6 +93,7 @@ describe("sessions table responsive styles", () => { ); expect(mobileCss).toContain(".data-table.sessions-table .data-table-key-col {"); expect(mobileCss).toContain(".sessions-table .session-status-col {"); + expect(mobileCss).toContain(".sessions-table .session-goal-chip {"); expect(mobileCss).not.toContain( ".sessions-table th:nth-child(5),\n .sessions-table td:nth-child(5)", ); diff --git a/ui/src/styles/layout.mobile.css b/ui/src/styles/layout.mobile.css index 88e4d3b8ef4e..9c8cb6839fdc 100644 --- a/ui/src/styles/layout.mobile.css +++ b/ui/src/styles/layout.mobile.css @@ -1064,6 +1064,17 @@ padding: 2px 6px; } + .sessions-table .session-goal-chip { + grid-template-columns: minmax(0, 1fr); + max-width: 72px; + gap: 0; + padding: 2px 6px; + } + + .sessions-table .session-goal-chip__objective { + display: none; + } + .sessions-table .session-compaction-col { width: 84px; min-width: 84px; diff --git a/ui/src/ui/app-chat.test.ts b/ui/src/ui/app-chat.test.ts index 561cae1678cd..c1dc62054a51 100644 --- a/ui/src/ui/app-chat.test.ts +++ b/ui/src/ui/app-chat.test.ts @@ -50,6 +50,7 @@ let refreshChatAvatar: typeof import("./app-chat.ts").refreshChatAvatar; let clearPendingQueueItemsForRun: typeof import("./app-chat.ts").clearPendingQueueItemsForRun; let removeQueuedMessage: typeof import("./app-chat.ts").removeQueuedMessage; let markQueuedChatSendsWaitingForReconnect: typeof import("./app-chat.ts").markQueuedChatSendsWaitingForReconnect; +let retryReconnectableQueuedChatSends: typeof import("./app-chat.ts").retryReconnectableQueuedChatSends; async function loadChatHelpers(): Promise { ({ @@ -63,6 +64,7 @@ async function loadChatHelpers(): Promise { clearPendingQueueItemsForRun, removeQueuedMessage, markQueuedChatSendsWaitingForReconnect, + retryReconnectableQueuedChatSends, } = await import("./app-chat.ts")); } @@ -245,6 +247,92 @@ describe("refreshChat", () => { expect(requestUpdate).not.toHaveBeenCalled(); }); + it("scopes global chat refresh session rows to the selected agent", async () => { + const request = vi.fn(() => new Promise(() => undefined)); + const host = makeHost({ + client: { request } as unknown as ChatHost["client"], + sessionKey: "global", + assistantAgentId: "work", + agentsList: { defaultId: "main" }, + }); + + const refresh = refreshChat(host); + const outcome = await raceWithMacrotask(refresh); + + expect(outcome).toBe("resolved"); + expect(request).toHaveBeenCalledWith("chat.history", { + sessionKey: "global", + agentId: "work", + limit: 100, + maxChars: 4000, + }); + const sessionsListPayload = findRequestPayload( + request as unknown as MockCallSource, + "sessions.list", + "global sessions list payload", + ); + expect(sessionsListPayload.agentId).toBe("work"); + expect(sessionsListPayload.includeGlobal).toBe(true); + }); + + it("scopes agent main aliases as selected global chat refreshes", async () => { + const request = vi.fn(() => new Promise(() => undefined)); + const host = makeHost({ + client: { request } as unknown as ChatHost["client"], + sessionKey: "agent:work:main", + agentsList: { defaultId: "main", mainKey: "main" }, + }); + + const refresh = refreshChat(host); + const outcome = await raceWithMacrotask(refresh); + + expect(outcome).toBe("resolved"); + expect(request).toHaveBeenCalledWith("chat.history", { + sessionKey: "agent:work:main", + agentId: "work", + limit: 100, + maxChars: 4000, + }); + const sessionsListPayload = findRequestPayload( + request as unknown as MockCallSource, + "sessions.list", + "global alias sessions list payload", + ); + expect(sessionsListPayload.agentId).toBe("work"); + expect(sessionsListPayload.includeGlobal).toBe(true); + }); + + it("uses hello default for global chat refresh before agents list loads", async () => { + const request = vi.fn(() => new Promise(() => undefined)); + const host = makeHost({ + client: { request } as unknown as ChatHost["client"], + sessionKey: "global", + hello: { + type: "hello-ok", + protocol: 4, + auth: { role: "operator", scopes: [] }, + snapshot: { sessionDefaults: { defaultAgentId: "ops" } }, + }, + }); + + const refresh = refreshChat(host); + const outcome = await raceWithMacrotask(refresh); + + expect(outcome).toBe("resolved"); + expect(request).toHaveBeenCalledWith("chat.history", { + sessionKey: "global", + agentId: "ops", + limit: 100, + maxChars: 4000, + }); + const sessionsListPayload = findRequestPayload( + request as unknown as MockCallSource, + "sessions.list", + "hello-default global sessions list payload", + ); + expect(sessionsListPayload.agentId).toBe("ops"); + }); + it("can wait for history without waiting for secondary metadata refreshes", async () => { const history = createDeferred(); const requestUpdate = vi.fn(); @@ -1376,6 +1464,48 @@ describe("handleSendChat", () => { expect(host.chatQueue[0]?.sendRunId).toEqual(expect.any(String)); }); + it("replays queued global sends under the originally selected agent", async () => { + const request = vi.fn((method: string) => { + if (method === "chat.send") { + return Promise.resolve({ runId: "run-work", status: "started" }); + } + throw new Error(`Unexpected request: ${method}`); + }); + const host = makeHost({ + client: null, + connected: false, + sessionKey: "global", + assistantAgentId: "work", + agentsList: { defaultId: "main" }, + chatMessage: "send to work later", + }); + + await handleSendChat(host); + + expect(host.chatQueue[0]).toMatchObject({ + text: "send to work later", + sessionKey: "global", + agentId: "work", + sendState: "waiting-reconnect", + }); + + host.assistantAgentId = "main"; + host.client = { request } as unknown as ChatHost["client"]; + host.connected = true; + await retryReconnectableQueuedChatSends(host); + + const payload = findRequestPayload( + request as unknown as MockCallSource, + "chat.send", + "queued global send payload", + ); + expect(payload.sessionKey).toBe("global"); + expect(payload.agentId).toBe("work"); + expect(host.chatMessages).toStrictEqual([]); + expect(host.chatRunId).toBeNull(); + expect(host.chatStream).toBeNull(); + }); + it("marks saved session queued sends waiting after a disconnect", () => { const host = makeHost({ chatQueue: [], @@ -1486,6 +1616,40 @@ describe("handleSendChat", () => { expect(host.chatStream).toBeNull(); }); + it("scopes /clear resets for selected-agent global sessions", async () => { + const request = vi.fn(async (method: string) => { + if (method === "sessions.reset") { + return { ok: true }; + } + if (method === "chat.history") { + return { messages: [], thinkingLevel: null }; + } + throw new Error(`Unexpected request: ${method}`); + }); + const host = makeHost({ + client: { request } as unknown as ChatHost["client"], + sessionKey: "global", + assistantAgentId: "work", + agentsList: { defaultId: "main" }, + chatMessage: "/clear", + chatMessages: [{ role: "user", content: "hello", timestamp: 1 }], + }); + + await handleSendChat(host); + + expect(request).toHaveBeenCalledWith("sessions.reset", { + key: "global", + agentId: "work", + }); + expect(request).toHaveBeenCalledWith("chat.history", { + sessionKey: "global", + agentId: "work", + limit: 100, + maxChars: 4000, + }); + expect(host.chatMessages).toStrictEqual([]); + }); + it("shows a visible pending item for /steer on the active run", async () => { const host = makeHost({ client: { @@ -1751,6 +1915,29 @@ describe("handleAbortChat", () => { expect(host.chatMessage).toBe(""); }); + it("queues selected-agent global aborts with agent scope while disconnected", async () => { + const host = makeHost({ + connected: false, + chatRunId: null, + chatMessage: "draft", + sessionKey: "global", + assistantAgentId: "work", + agentsList: { defaultId: "main" }, + sessionsResult: createSessionsResult([ + row("global", { hasActiveRun: true, agentId: "work" } as Partial), + ]), + }); + + await handleAbortChat(host); + + expect(host.pendingAbort).toEqual({ + runId: null, + sessionKey: "global", + agentId: "work", + }); + expect(host.chatMessage).toBe(""); + }); + it("ignores stale active-run flags once the current session is terminal", () => { const host = makeHost({ chatRunId: null, diff --git a/ui/src/ui/app-chat.ts b/ui/src/ui/app-chat.ts index bc8d76c65f42..aa233d898e0d 100644 --- a/ui/src/ui/app-chat.ts +++ b/ui/src/ui/app-chat.ts @@ -40,7 +40,7 @@ import { } from "./controllers/sessions.ts"; import { GatewayRequestError, type GatewayBrowserClient, type GatewayHelloOk } from "./gateway.ts"; import { normalizeBasePath } from "./navigation.ts"; -import { parseAgentSessionKey } from "./session-key.ts"; +import { DEFAULT_AGENT_ID, normalizeAgentId, parseAgentSessionKey } from "./session-key.ts"; import { isSessionRunActive } from "./session-run-state.ts"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalString } from "./string-coerce.ts"; import type { ChatModelOverride, ModelCatalogEntry } from "./types.ts"; @@ -78,8 +78,10 @@ export type ChatHost = ChatInputHistoryState & { updateComplete?: Promise; requestUpdate?: () => void; refreshSessionsAfterChat: Set; - pendingAbort?: { runId?: string | null; sessionKey: string } | null; + pendingAbort?: { runId?: string | null; sessionKey: string; agentId?: string } | null; chatSubmitGuards?: Map>; + assistantAgentId?: string | null; + agentsList?: { defaultId?: string | null; mainKey?: string | null } | null; /** Callback for slash-command side effects that need app-level access. */ onSlashAction?: (action: string) => void | Promise; }; @@ -93,6 +95,11 @@ export type ChatAbortOptions = { preserveDraft?: boolean; }; +type SessionDefaultsSnapshot = { + defaultAgentId?: string; + mainKey?: string; +}; + // Chat pickers need recency-free session rows so older channel chats remain selectable. export const CHAT_SESSIONS_ACTIVE_MINUTES = 0; export const CHAT_SESSIONS_REFRESH_LIMIT = 50; @@ -198,6 +205,92 @@ function isBtwCommand(text: string) { return /^\/(?:btw|side)(?::|\s|$)/i.test(text.trim()); } +function isGlobalSessionKey(sessionKey: string | undefined | null): boolean { + return normalizeLowercaseStringOrEmpty(sessionKey) === "global"; +} + +function readHelloDefaultAgentId(host: Pick): string | undefined { + const snapshot = host.hello?.snapshot as + | { sessionDefaults?: SessionDefaultsSnapshot } + | undefined; + return snapshot?.sessionDefaults?.defaultAgentId?.trim() || undefined; +} + +function readHelloMainKey(host: Pick): string | undefined { + const snapshot = host.hello?.snapshot as + | { sessionDefaults?: SessionDefaultsSnapshot } + | undefined; + return snapshot?.sessionDefaults?.mainKey?.trim() || undefined; +} + +function resolveGlobalAliasAgentId( + host: Pick, + sessionKey: string | undefined | null, +): string | undefined { + const parsed = parseAgentSessionKey(sessionKey); + if (!parsed) { + return undefined; + } + const rest = normalizeLowercaseStringOrEmpty(parsed.rest); + const configuredMainKey = normalizeLowercaseStringOrEmpty( + host.agentsList?.mainKey ?? readHelloMainKey(host) ?? "main", + ); + return rest === "main" || rest === configuredMainKey + ? normalizeAgentId(parsed.agentId) + : undefined; +} + +function resolveSelectedGlobalAgentId( + host: Pick, +): string | undefined { + const agentId = + host.assistantAgentId?.trim() || + host.agentsList?.defaultId?.trim() || + readHelloDefaultAgentId(host); + return agentId ? normalizeAgentId(agentId) : undefined; +} + +function scopedAgentIdForSession(host: ChatHost, sessionKey: string | undefined | null) { + return isGlobalSessionKey(sessionKey) + ? resolveSelectedGlobalAgentId(host) + : resolveGlobalAliasAgentId(host, sessionKey); +} + +function visibleSessionMatches( + host: ChatHost, + sessionKey: string, + agentId: string | undefined, +): boolean { + if (host.sessionKey !== sessionKey) { + const hostAliasAgentId = resolveGlobalAliasAgentId(host, host.sessionKey); + if (!hostAliasAgentId || !isGlobalSessionKey(sessionKey)) { + return false; + } + const expectedAgentId = agentId ?? host.agentsList?.defaultId ?? readHelloDefaultAgentId(host); + return expectedAgentId + ? hostAliasAgentId === normalizeAgentId(expectedAgentId) + : hostAliasAgentId === normalizeAgentId("main"); + } + if (!isGlobalSessionKey(sessionKey)) { + return true; + } + const selectedAgentId = resolveSelectedGlobalAgentId(host); + const expectedAgentId = agentId ?? host.agentsList?.defaultId ?? readHelloDefaultAgentId(host); + return expectedAgentId + ? selectedAgentId === normalizeAgentId(expectedAgentId) + : selectedAgentId === undefined; +} + +export function scopedAgentParamsForSession( + host: Pick, + sessionKey: string, +) { + const agentId = isGlobalSessionKey(sessionKey) + ? resolveSelectedGlobalAgentId(host) + : resolveGlobalAliasAgentId(host, sessionKey); + return agentId ? { agentId } : {}; +} + export async function handleAbortChat(host: ChatHost, opts?: ChatAbortOptions) { const activeRunId = host.chatRunId; const clearDraft = () => { @@ -210,7 +303,11 @@ export async function handleAbortChat(host: ChatHost, opts?: ChatAbortOptions) { // If disconnected but this session is abortable, queue the abort for when we reconnect. if (!host.connected && hasAbortableSessionRun(host)) { clearDraft(); - host.pendingAbort = { runId: activeRunId, sessionKey: host.sessionKey }; + host.pendingAbort = { + runId: activeRunId, + sessionKey: host.sessionKey, + ...scopedAgentParamsForSession(host, host.sessionKey), + }; return; } if (!host.connected) { @@ -241,6 +338,7 @@ function enqueueChatMessage( localCommandArgs: localCommand?.args, localCommandName: localCommand?.name, sessionKey: host.sessionKey, + agentId: scopedAgentIdForSession(host, host.sessionKey), }; host.chatQueue = [...host.chatQueue, item]; return item; @@ -291,6 +389,7 @@ function enqueuePendingSendMessage( sendRunId: generateUUID(), sendState: host.connected && host.client ? "sending" : "waiting-reconnect", sessionKey: host.sessionKey, + agentId: scopedAgentIdForSession(host, host.sessionKey), }; host.chatQueue = [...host.chatQueue, pending]; return pending; @@ -391,12 +490,14 @@ function ensureQueuedSendState( return item; } const sessionKey = item.sessionKey ?? fallbackSessionKey; + const agentId = item.agentId ?? scopedAgentIdForSession(host, sessionKey); const prepared: ChatQueueItem = { ...item, sendAttempts: item.sendAttempts ?? 0, sendRunId: item.sendRunId ?? generateUUID(), sendState: host.connected && host.client ? "sending" : "waiting-reconnect", sessionKey, + agentId, }; updateQueuedMessageForSession(host, sessionKey, item.id, () => prepared); return prepared; @@ -442,9 +543,11 @@ async function sendQueuedChatMessage( sendRunId: runId, sendState: "sending", sessionKey, + agentId: prepared.agentId, })); host.chatSending = true; - if (host.sessionKey === sessionKey) { + const isVisibleSession = () => visibleSessionMatches(host, sessionKey, prepared.agentId); + if (isVisibleSession()) { host.lastError = null; reconcileChatRunLifecycle(host as unknown as Parameters[0], { clearRunStatus: true, @@ -457,9 +560,10 @@ async function sendQueuedChatMessage( attachments: hasAttachments ? attachments : undefined, runId, sessionKey, + agentId: prepared.agentId, }); removeQueuedMessageWithoutReleasing(host, id, sessionKey); - if (host.sessionKey === sessionKey) { + if (isVisibleSession()) { appendUserChatMessage( host as unknown as ChatState, message, @@ -507,7 +611,7 @@ async function sendQueuedChatMessage( sendError: error, sendState: "waiting-reconnect", })); - if (host.sessionKey === sessionKey) { + if (isVisibleSession()) { host.lastError = "Message will send when the Gateway reconnects."; } return "pending"; @@ -517,7 +621,7 @@ async function sendQueuedChatMessage( sendError: error, sendState: "failed", })); - if (host.sessionKey === sessionKey) { + if (isVisibleSession()) { host.lastError = error; restoreComposerAfterFailedSend(host, opts ?? {}); } @@ -1102,6 +1206,7 @@ async function dispatchSlashCommand( result = await executeSlashCommand(host.client, targetSessionKey, name, args, { chatModelCatalog: host.chatModelCatalog, sessionsResult: host.sessionsResult, + agentId: scopedAgentIdForSession(host, targetSessionKey), }); } catch (err) { host.lastError = String(err); @@ -1145,7 +1250,10 @@ async function clearChatHistory(host: ChatHost) { } const hadActiveRun = hasAbortableSessionRun(host); try { - await host.client.request("sessions.reset", { key: host.sessionKey }); + await host.client.request("sessions.reset", { + key: host.sessionKey, + ...scopedAgentParamsForSession(host, host.sessionKey), + }); host.chatMessages = []; host.chatSideResult = null; reconcileChatRunLifecycle(host as unknown as Parameters[0], { @@ -1191,6 +1299,7 @@ export async function refreshChat( const secondaryRefresh = Promise.allSettled([ loadSessions(host as unknown as SessionsState, { ...createChatSessionsLoadOverrides(host), + ...scopedAgentParamsForSession(host, host.sessionKey), }), refreshChatAvatar(host), refreshChatModels(host), @@ -1229,10 +1338,6 @@ async function refreshChatCommands(host: ChatHost) { export const flushChatQueueForEvent = flushChatQueue; const chatAvatarRequestVersions = new WeakMap(); -type SessionDefaultsSnapshot = { - defaultAgentId?: string; -}; - const chatAvatarObjectUrls = new WeakMap(); function beginChatAvatarRequest(host: ChatHost): number { @@ -1253,11 +1358,7 @@ function resolveAgentIdForSession(host: ChatHost): string | null { if (parsed?.agentId) { return parsed.agentId; } - const snapshot = host.hello?.snapshot as - | { sessionDefaults?: SessionDefaultsSnapshot } - | undefined; - const fallback = snapshot?.sessionDefaults?.defaultAgentId?.trim(); - return fallback || "main"; + return readHelloDefaultAgentId(host) || DEFAULT_AGENT_ID; } function buildAvatarMetaUrl(basePath: string, agentId: string): string { diff --git a/ui/src/ui/app-gateway.node.test.ts b/ui/src/ui/app-gateway.node.test.ts index 42fcc87c1c60..4d233979ce60 100644 --- a/ui/src/ui/app-gateway.node.test.ts +++ b/ui/src/ui/app-gateway.node.test.ts @@ -949,6 +949,25 @@ describe("connectGateway", () => { expect(host.pendingAbort).toBeNull(); }); + it("replays queued selected-global chat aborts with agent scope", async () => { + const host = createHost(); + host.pendingAbort = { sessionKey: "global", agentId: "work" }; + host.assistantAgentId = "main"; + host.agentsList = { defaultId: "main", agents: [], mainKey: "main", scope: "global" }; + + connectGateway(host); + const client = requireGatewayClient(); + + client.emitHello(); + await Promise.resolve(); + + expect(client.request).toHaveBeenCalledWith("chat.abort", { + sessionKey: "global", + agentId: "work", + }); + expect(host.pendingAbort).toBeNull(); + }); + it("retries reconnectable queued chat sends after reconnect hello", async () => { const host = createHost(); host.chatQueue = [ @@ -1153,6 +1172,58 @@ describe("connectGateway", () => { expect(host.chatSideResultTerminalRuns.has("btw-run-1")).toBe(true); }); + it("stores selected-global BTW side results for agent main aliases", () => { + const { host, client } = connectHostGateway(); + host.sessionKey = "agent:work:main"; + host.agentsList = { defaultId: "main", agents: [], mainKey: "main", scope: "global" }; + + client.emitEvent({ + event: "chat.side_result", + payload: { + kind: "btw", + runId: "btw-work-global", + sessionKey: "global", + agentId: "work", + question: "what changed?", + text: "The alias now receives canonical global side results.", + ts: 123, + }, + }); + + const sideResult = host.chatSideResult as + | { agentId?: string; kind?: string; runId?: string; sessionKey?: string; text?: string } + | undefined; + expect(sideResult?.kind).toBe("btw"); + expect(sideResult?.runId).toBe("btw-work-global"); + expect(sideResult?.sessionKey).toBe("global"); + expect(sideResult?.agentId).toBe("work"); + expect(sideResult?.text).toBe("The alias now receives canonical global side results."); + expect(host.chatSideResultTerminalRuns.has("btw-work-global")).toBe(true); + }); + + it("ignores selected-global BTW side results from another agent", () => { + const { host, client } = connectHostGateway(); + host.sessionKey = "global"; + host.assistantAgentId = "work"; + host.agentsList = { defaultId: "main", agents: [], mainKey: "main", scope: "global" }; + + client.emitEvent({ + event: "chat.side_result", + payload: { + kind: "btw", + runId: "btw-main-global", + sessionKey: "global", + agentId: "main", + question: "what changed?", + text: "This belongs to the default agent.", + ts: 123, + }, + }); + + expect(host.chatSideResult).toBeNull(); + expect(host.chatSideResultTerminalRuns.has("btw-main-global")).toBe(false); + }); + it("ignores tracked BTW terminal finals without tearing down the active run", () => { const { host, client } = connectHostGateway(); host.chatRunId = "main-run-1"; diff --git a/ui/src/ui/app-gateway.sessions.node.test.ts b/ui/src/ui/app-gateway.sessions.node.test.ts index 1592c9576a8e..6f6d1d0e3d49 100644 --- a/ui/src/ui/app-gateway.sessions.node.test.ts +++ b/ui/src/ui/app-gateway.sessions.node.test.ts @@ -13,6 +13,8 @@ vi.mock("./app-chat.ts", () => ({ CHAT_SESSIONS_ACTIVE_MINUTES: 10, CHAT_SESSIONS_REFRESH_LIMIT: 25, createChatSessionsLoadOverrides: () => ({ activeMinutes: 10, limit: 25 }), + scopedAgentParamsForSession: (host: { assistantAgentId?: string | null }, sessionKey: string) => + sessionKey === "global" && host.assistantAgentId ? { agentId: host.assistantAgentId } : {}, clearPendingQueueItemsForRun: clearPendingQueueItemsForRunMock, flushChatQueueForEvent: flushChatQueueForEventMock, refreshChatAvatar: vi.fn(), @@ -166,6 +168,28 @@ describe("handleGatewayEvent sessions.changed", () => { }); }); + it("scopes selected-global chat session refreshes after a completed run", () => { + loadSessionsMock.mockReset(); + handleChatEventMock.mockReset().mockReturnValue("final"); + const host = createHost(); + host.sessionKey = "global"; + host.assistantAgentId = "work"; + host.refreshSessionsAfterChat.add("run-1"); + + handleGatewayEvent(host, { + type: "event", + event: "chat", + payload: { state: "final", runId: "run-1", sessionKey: "global", agentId: "work" }, + seq: 1, + }); + + expect(loadSessionsMock).toHaveBeenCalledWith(host, { + activeMinutes: 10, + limit: 25, + agentId: "work", + }); + }); + it("applies reliable session change snapshots without refetching the list", () => { loadSessionsMock.mockReset(); handleChatEventMock.mockReset().mockReturnValue("idle"); @@ -549,6 +573,45 @@ describe("handleGatewayEvent session.message", () => { expect(loadChatHistoryMock).toHaveBeenCalledWith(host); }); + it("reloads chat history for selected agent main aliases receiving canonical global messages", () => { + loadChatHistoryMock.mockReset(); + loadSessionsMock.mockReset(); + applySessionsChangedEventMock.mockReset().mockReturnValue({ applied: false }); + const host = createHost(); + host.sessionKey = "agent:work:main"; + + handleGatewayEvent(host, { + type: "event", + event: "session.message", + payload: { sessionKey: "global", agentId: "work" }, + seq: 1, + }); + + expect(applySessionsChangedEventMock).toHaveBeenCalledTimes(1); + expect(loadChatHistoryMock).toHaveBeenCalledTimes(1); + expect(loadChatHistoryMock).toHaveBeenCalledWith(host); + expect(loadSessionsMock).not.toHaveBeenCalled(); + }); + + it("ignores canonical global messages for other agent main aliases", () => { + loadChatHistoryMock.mockReset(); + loadSessionsMock.mockReset(); + applySessionsChangedEventMock.mockReset().mockReturnValue({ applied: false }); + const host = createHost(); + host.sessionKey = "agent:work:main"; + + handleGatewayEvent(host, { + type: "event", + event: "session.message", + payload: { sessionKey: "global", agentId: "main" }, + seq: 1, + }); + + expect(applySessionsChangedEventMock).not.toHaveBeenCalled(); + expect(loadChatHistoryMock).not.toHaveBeenCalled(); + expect(loadSessionsMock).not.toHaveBeenCalled(); + }); + it("reloads history before flushing queue when session.message clears the run", async () => { loadChatHistoryMock.mockReset(); clearPendingQueueItemsForRunMock.mockReset(); @@ -628,6 +691,80 @@ describe("handleGatewayEvent session.message", () => { expect(loadChatHistoryMock).not.toHaveBeenCalled(); }); + it("scopes selected-global session.message refreshes while a chat run is active", async () => { + loadChatHistoryMock.mockReset(); + applySessionsChangedEventMock.mockReset().mockReturnValue({ applied: false }); + loadSessionsMock.mockReset().mockResolvedValue(undefined); + const host = createHost(); + host.sessionKey = "global"; + host.assistantAgentId = "work"; + host.chatRunId = "run-123"; + + handleGatewayEvent(host, { + type: "event", + event: "session.message", + payload: { sessionKey: "global", agentId: "work" }, + seq: 1, + }); + + expect(loadChatHistoryMock).not.toHaveBeenCalled(); + expect(loadSessionsMock).toHaveBeenCalledWith(host, { + activeMinutes: 10, + limit: 25, + agentId: "work", + publishChatRunStatus: false, + }); + }); + + it("ignores selected-global session.message events from other agents", () => { + loadChatHistoryMock.mockReset(); + applySessionsChangedEventMock.mockReset().mockReturnValue({ applied: false }); + loadSessionsMock.mockReset(); + const host = createHost(); + host.sessionKey = "global"; + host.assistantAgentId = "work"; + + handleGatewayEvent(host, { + type: "event", + event: "session.message", + payload: { sessionKey: "global", agentId: "main" }, + seq: 1, + }); + + expect(applySessionsChangedEventMock).not.toHaveBeenCalled(); + expect(loadChatHistoryMock).not.toHaveBeenCalled(); + expect(loadSessionsMock).not.toHaveBeenCalled(); + }); + + it("uses hello default agent for unscoped global session.message events before agents load", () => { + loadChatHistoryMock.mockReset(); + applySessionsChangedEventMock.mockReset().mockReturnValue({ applied: false }); + loadSessionsMock.mockReset(); + const host = createHost(); + host.sessionKey = "global"; + host.hello = { + type: "hello-ok", + protocol: 4, + auth: { role: "operator", scopes: [] }, + snapshot: { + sessionDefaults: { + defaultAgentId: "work", + }, + }, + }; + + handleGatewayEvent(host, { + type: "event", + event: "session.message", + payload: { sessionKey: "global" }, + seq: 1, + }); + + expect(applySessionsChangedEventMock).toHaveBeenCalled(); + expect(loadChatHistoryMock).toHaveBeenCalledWith(host); + expect(loadSessionsMock).not.toHaveBeenCalled(); + }); + it("replays deferred history reload after session refresh clears a stale active run", async () => { loadChatHistoryMock.mockReset(); applySessionsChangedEventMock.mockReset().mockReturnValue({ applied: false }); diff --git a/ui/src/ui/app-gateway.ts b/ui/src/ui/app-gateway.ts index 1e520d12616c..cadf75e6f902 100644 --- a/ui/src/ui/app-gateway.ts +++ b/ui/src/ui/app-gateway.ts @@ -11,6 +11,7 @@ import { markQueuedChatSendsWaitingForReconnect, refreshChatAvatar, retryReconnectableQueuedChatSends, + scopedAgentParamsForSession, } from "./app-chat.ts"; import type { EventLogEntry } from "./app-events.ts"; import { @@ -120,7 +121,7 @@ type GatewayHost = { sessionKey: string; sessionsShowArchived: boolean; chatRunId: string | null; - pendingAbort?: { runId?: string | null; sessionKey: string } | null; + pendingAbort?: { runId?: string | null; sessionKey: string; agentId?: string } | null; refreshSessionsAfterChat: Set; sessionsLoading?: boolean; execApprovalQueue: ExecApprovalRequest[]; @@ -464,6 +465,85 @@ function resolveMainSessionFallback(host: GatewayHost): string { }); } +function isGlobalSessionKey(sessionKey: string | undefined | null): boolean { + return sessionKey?.trim().toLowerCase() === "global"; +} + +function resolveDefaultAgentId(host: GatewayHost): string { + const snapshot = host.hello?.snapshot as + | { sessionDefaults?: SessionDefaultsSnapshot } + | undefined; + return normalizeAgentId( + host.agentsList?.defaultId?.trim() || + snapshot?.sessionDefaults?.defaultAgentId?.trim() || + "main", + ); +} + +function resolveSelectedGlobalAgentId(host: GatewayHost): string { + return normalizeAgentId(host.assistantAgentId?.trim() || resolveDefaultAgentId(host)); +} + +function resolveGlobalAliasAgentId(host: GatewayHost, sessionKey: string | undefined | null) { + const parsed = parseAgentSessionKey(sessionKey ?? ""); + if (!parsed) { + return undefined; + } + const rest = parsed.rest.trim().toLowerCase(); + const snapshot = host.hello?.snapshot as + | { sessionDefaults?: SessionDefaultsSnapshot } + | undefined; + const mainKey = snapshot?.sessionDefaults?.mainKey?.trim().toLowerCase() || "main"; + return rest === "main" || rest === mainKey ? normalizeAgentId(parsed.agentId) : undefined; +} + +function resolveSelectedGlobalEventAgentId( + host: GatewayHost, + agentId: string | undefined | null, +): string { + return agentId ? normalizeAgentId(agentId) : resolveDefaultAgentId(host); +} + +function globalAgentScopeMatches( + host: GatewayHost, + sessionKey: string | undefined | null, + agentId: string | undefined | null, +): boolean { + if (!isGlobalSessionKey(sessionKey)) { + return true; + } + const selectedAgentId = isGlobalSessionKey(host.sessionKey) + ? resolveSelectedGlobalAgentId(host) + : resolveGlobalAliasAgentId(host, host.sessionKey); + if (!selectedAgentId) { + return true; + } + return resolveSelectedGlobalEventAgentId(host, agentId) === selectedAgentId; +} + +function sessionMessageMatchesHost( + host: GatewayHost, + sessionKey: string | undefined, + agentId: string | undefined | null, +): boolean { + if (!sessionKey) { + return false; + } + if (areUiSessionKeysEquivalent(sessionKey, host.sessionKey)) { + return true; + } + const hostAliasAgentId = resolveGlobalAliasAgentId(host, host.sessionKey); + return Boolean( + hostAliasAgentId && + isGlobalSessionKey(sessionKey) && + resolveSelectedGlobalEventAgentId(host, agentId) === hostAliasAgentId, + ); +} + +function chatSideResultAgentScopeMatches(host: GatewayHost, sideResult: ChatSideResult): boolean { + return globalAgentScopeMatches(host, sideResult.sessionKey, sideResult.agentId); +} + function fallbackUnconfiguredSessionSelection(host: GatewayHost) { const parsed = parseAgentSessionKey(host.sessionKey); if (!parsed) { @@ -555,8 +635,17 @@ export function connectGateway(host: GatewayHost, options?: ConnectGatewayOption .request( "chat.abort", abort.runId - ? { sessionKey: abort.sessionKey, runId: abort.runId } - : { sessionKey: abort.sessionKey }, + ? { + sessionKey: abort.sessionKey, + ...scopedAgentParamsForSession(host, abort.sessionKey), + ...(abort.agentId ? { agentId: abort.agentId } : {}), + runId: abort.runId, + } + : { + sessionKey: abort.sessionKey, + ...scopedAgentParamsForSession(host, abort.sessionKey), + ...(abort.agentId ? { agentId: abort.agentId } : {}), + }, ) .catch((err) => { // Log to console for diagnostics; user sees no feedback for a stale abort @@ -710,6 +799,7 @@ function handleTerminalChatEvent( if (state === "final") { void loadSessions(host as unknown as SessionsState, { ...createChatSessionsLoadOverrides(host), + ...scopedAgentParamsForSession(host, host.sessionKey), }); } } @@ -850,13 +940,14 @@ function flushChatQueueAfterSessionRunReconcile( function handleSessionMessageGatewayEvent( host: GatewayHost, - payload: { sessionKey?: string; runId?: unknown } | undefined, + payload: { sessionKey?: string; agentId?: string; runId?: unknown } | undefined, ) { const deferredReloadHost = host as GatewayHostWithDeferredSessionMessageReload; const sessionKey = payload?.sessionKey?.trim(); - const sessionMatchesHost = Boolean( - sessionKey && areUiSessionKeysEquivalent(sessionKey, host.sessionKey), - ); + if (!globalAgentScopeMatches(host, sessionKey, payload?.agentId)) { + return; + } + const sessionMatchesHost = sessionMessageMatchesHost(host, sessionKey, payload?.agentId); const runIdBeforeApply = host.chatRunId; const result = applySessionsChangedEvent(host as unknown as SessionsState, payload); if (result.applied && result.clearedChatRun) { @@ -881,11 +972,13 @@ function handleSessionMessageGatewayEvent( const runIdBeforeRefresh = host.chatRunId; void loadSessions(host as unknown as SessionsState, { ...createChatSessionsLoadOverrides(host), + ...scopedAgentParamsForSession(host, host.sessionKey), publishChatRunStatus: false, }).finally(() => replayDeferredSessionMessageReloadAfterSessionsRefresh( host, sessionKey, + payload?.agentId, refreshStartedAt, runIdBeforeRefresh, ), @@ -899,6 +992,7 @@ function handleSessionMessageGatewayEvent( function replayDeferredSessionMessageReloadAfterSessionsRefresh( host: GatewayHost, sessionKey: string, + agentId: string | undefined | null, startedAt: number, completedRunId?: string | null, ) { @@ -908,7 +1002,7 @@ function replayDeferredSessionMessageReloadAfterSessionsRefresh( deferredReloadHost.pendingSessionMessageReloadSessionKey?.trim() ?? "", sessionKey, ) || - !areUiSessionKeysEquivalent(host.sessionKey, sessionKey) + !sessionMessageMatchesHost(host, sessionKey, agentId) ) { return; } @@ -922,6 +1016,7 @@ function replayDeferredSessionMessageReloadAfterSessionsRefresh( replayDeferredSessionMessageReloadAfterSessionsRefresh( host, sessionKey, + agentId, startedAt, completedRunId, ), @@ -981,7 +1076,11 @@ function handleGatewayEventUnsafe(host: GatewayHost, evt: GatewayEventFrame) { if (evt.event === "chat.side_result") { const sideResult = parseChatSideResult(evt.payload); - if (!sideResult || sideResult.sessionKey !== host.sessionKey) { + if ( + !sideResult || + !sessionMessageMatchesHost(host, sideResult.sessionKey, sideResult.agentId) || + !chatSideResultAgentScopeMatches(host, sideResult) + ) { return; } const sideResultHost = host as GatewayHostWithSideResults; @@ -991,7 +1090,10 @@ function handleGatewayEventUnsafe(host: GatewayHost, evt: GatewayEventFrame) { } if (evt.event === "session.message") { - handleSessionMessageGatewayEvent(host, evt.payload as { sessionKey?: string } | undefined); + handleSessionMessageGatewayEvent( + host, + evt.payload as { sessionKey?: string; agentId?: string } | undefined, + ); return; } diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 7ab3d1fa1fef..7e83d8bfa545 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -6,6 +6,7 @@ import { createChatSessionsLoadOverrides, hasAbortableSessionRun, refreshChat, + scopedAgentParamsForSession, } from "./app-chat.ts"; import { DEFAULT_CRON_FORM } from "./app-defaults.ts"; import { renderUsageTab } from "./app-render-usage-tab.ts"; @@ -2907,7 +2908,10 @@ export function renderApp(state: AppViewState) { } const hadActiveRun = hasAbortableSessionRun(state); try { - await state.client.request("sessions.reset", { key: state.sessionKey }); + await state.client.request("sessions.reset", { + key: state.sessionKey, + ...scopedAgentParamsForSession(state, state.sessionKey), + }); state.chatMessages = []; state.chatSideResult = null; reconcileChatRunLifecycle( diff --git a/ui/src/ui/app-tool-stream.node.test.ts b/ui/src/ui/app-tool-stream.node.test.ts index d430a89a32bb..3327dd4efa6e 100644 --- a/ui/src/ui/app-tool-stream.node.test.ts +++ b/ui/src/ui/app-tool-stream.node.test.ts @@ -325,6 +325,82 @@ describe("app-tool-stream fallback lifecycle handling", () => { vi.useRealTimers(); }); + it("ignores selected-global tool events from another agent", () => { + const host = createHost({ + sessionKey: "global", + assistantAgentId: "work", + agentsList: { defaultId: "main" }, + }); + + handleAgentEvent(host, { + runId: "run-main-global", + seq: 1, + stream: "tool", + ts: Date.now(), + sessionKey: "global", + agentId: "main", + data: { + phase: "start", + name: "exec", + toolCallId: "tool-main-global", + }, + }); + + expect(host.toolStreamOrder).toHaveLength(0); + expect(host.activityEntries).toHaveLength(0); + }); + + it("ignores selected-global lifecycle and fallback events from another agent", () => { + const host = createHost({ + sessionKey: "global", + assistantAgentId: "work", + agentsList: { defaultId: "main" }, + }); + + handleAgentEvent(host, { + runId: "run-main-global", + seq: 1, + stream: "compaction", + ts: Date.now(), + sessionKey: "global", + agentId: "main", + data: { phase: "start" }, + }); + handleAgentEvent(host, { + runId: "run-main-global", + seq: 2, + stream: "lifecycle", + ts: Date.now(), + sessionKey: "global", + agentId: "main", + data: { + phase: "fallback", + selectedProvider: "fireworks", + selectedModel: "fireworks/accounts/fireworks/routers/kimi-k2p5-turbo", + activeProvider: "deepinfra", + activeModel: "moonshotai/Kimi-K2.5", + }, + }); + handleAgentEvent(host, { + runId: "run-main-global", + seq: 3, + stream: "fallback", + ts: Date.now(), + sessionKey: "global", + agentId: "main", + data: { + phase: "fallback", + selectedProvider: "fireworks", + selectedModel: "fireworks/accounts/fireworks/routers/kimi-k2p5-turbo", + activeProvider: "deepinfra", + activeModel: "moonshotai/Kimi-K2.5", + }, + }); + + expect(host.compactionStatus).toBeNull(); + expect(host.fallbackStatus).toBeNull(); + }); + it("stores only redacted truncated output previews in activity entries", () => { useToolStreamFakeTimers(); const host = createHost(); @@ -566,6 +642,72 @@ describe("app-tool-stream fallback lifecycle handling", () => { vi.useRealTimers(); }); + it("ignores selected-global session operation compaction for another agent", () => { + useToolStreamFakeTimers(); + const host = createHost({ + sessionKey: "global", + assistantAgentId: "work", + agentsList: { defaultId: "main" }, + }); + + handleSessionOperationEvent(host, { + operationId: "operation-main", + operation: "compact", + phase: "start", + sessionKey: "global", + agentId: "main", + ts: TOOL_STREAM_TEST_NOW, + }); + + expect(host.compactionStatus).toBeNull(); + expect(host.compactionClearTimer).toBeNull(); + + vi.useRealTimers(); + }); + + it("accepts canonical global live events for selected agent main aliases", () => { + useToolStreamFakeTimers(); + const host = createHost({ + sessionKey: "agent:work:main", + agentsList: { defaultId: "main" }, + }); + + handleAgentEvent(host, { + runId: "run-work", + seq: 1, + stream: "compaction", + ts: TOOL_STREAM_TEST_NOW, + sessionKey: "global", + agentId: "work", + data: { phase: "start" }, + }); + + expect(host.compactionStatus).toEqual({ + phase: "active", + runId: "run-work", + startedAt: TOOL_STREAM_TEST_NOW, + completedAt: null, + }); + + handleAgentEvent(host, { + runId: "run-main", + seq: 2, + stream: "fallback", + ts: TOOL_STREAM_TEST_NOW, + sessionKey: "global", + agentId: "main", + data: { + phase: "fallback_started", + selectedProvider: "openai", + selectedModel: "gpt-5", + }, + }); + + expect(host.fallbackStatus).toBeNull(); + + vi.useRealTimers(); + }); + it("ignores stale manual session operation completion after a newer start", () => { useToolStreamFakeTimers(); const host = createHost({ sessionKey: "agent:main:main" }); diff --git a/ui/src/ui/app-tool-stream.ts b/ui/src/ui/app-tool-stream.ts index 13fb1b748dea..ec888650c570 100644 --- a/ui/src/ui/app-tool-stream.ts +++ b/ui/src/ui/app-tool-stream.ts @@ -2,7 +2,13 @@ import { updateActivityFromToolEvent, type ActivityEntry } from "./activity-mode import { createChatModelOverride } from "./chat-model-ref.ts"; import type { ChatModelOverride } from "./chat-model-ref.types.ts"; import { formatUnknownText, truncateText } from "./format.ts"; -import { buildAgentMainSessionKey, DEFAULT_AGENT_ID, DEFAULT_MAIN_KEY } from "./session-key.ts"; +import { + buildAgentMainSessionKey, + DEFAULT_AGENT_ID, + DEFAULT_MAIN_KEY, + normalizeAgentId, + parseAgentSessionKey, +} from "./session-key.ts"; import { normalizeLowercaseStringOrEmpty } from "./string-coerce.ts"; const TOOL_STREAM_LIMIT = 50; @@ -15,6 +21,7 @@ export type AgentEventPayload = { stream: string; ts: number; sessionKey?: string; + agentId?: string; data: Record; }; @@ -23,6 +30,7 @@ export type SessionOperationEventPayload = { operation?: string; phase?: string; sessionKey?: string; + agentId?: string; ts?: number; completed?: boolean; reason?: string; @@ -42,6 +50,8 @@ export type ToolStreamEntry = { type ToolStreamHost = { sessionKey: string; + assistantAgentId?: string | null; + agentsList?: { defaultId?: string | null } | null; hello?: { snapshot?: { sessionDefaults?: SessionDefaultsSnapshot; @@ -230,7 +240,9 @@ function syncSessionStatusModelOverride(host: ToolStreamHost, data: Record; -type RunLifecycleHost = Partial[0]> & { +type RunLifecycleHost = Omit[0]>, "hello"> & { sessionKey: string; chatRunId?: string | null; chatStream?: string | null; diff --git a/ui/src/ui/chat/side-result.ts b/ui/src/ui/chat/side-result.ts index 5250f65de522..e0644a89d349 100644 --- a/ui/src/ui/chat/side-result.ts +++ b/ui/src/ui/chat/side-result.ts @@ -4,6 +4,7 @@ export type ChatSideResult = { kind: "btw"; runId: string; sessionKey: string; + agentId?: string; question: string; text: string; isError: boolean; @@ -29,6 +30,9 @@ export function parseChatSideResult(payload: unknown): ChatSideResult | null { kind: "btw", runId, sessionKey, + ...(normalizeOptionalString(candidate.agentId) + ? { agentId: normalizeOptionalString(candidate.agentId) } + : {}), question, text, isError: candidate.isError === true, diff --git a/ui/src/ui/chat/slash-command-executor.node.test.ts b/ui/src/ui/chat/slash-command-executor.node.test.ts index 0e3edf746e25..1dd81aab6e69 100644 --- a/ui/src/ui/chat/slash-command-executor.node.test.ts +++ b/ui/src/ui/chat/slash-command-executor.node.test.ts @@ -104,6 +104,54 @@ describe("executeSlashCommand directives", () => { }); }); + it("passes selected-agent scope for global model changes", async () => { + const request = vi.fn(async (method: string, _payload?: unknown) => { + if (method === "sessions.patch") { + return createResolvedModelPatch("gpt-5-mini", "openai"); + } + throw new Error(`unexpected method: ${method}`); + }); + + await executeSlashCommand( + { request } as unknown as GatewayBrowserClient, + "global", + "model", + "gpt-5-mini", + { + agentId: "work", + chatModelCatalog: [{ id: "gpt-5-mini", name: "gpt-5-mini", provider: "openai" }], + }, + ); + + expect(request).toHaveBeenCalledWith("sessions.patch", { + key: "global", + agentId: "work", + model: "gpt-5-mini", + }); + }); + + it("passes selected-agent scope for global compaction", async () => { + const request = vi.fn(async (method: string, _payload?: unknown) => { + if (method === "sessions.compact") { + return { ok: true, compacted: false }; + } + throw new Error(`unexpected method: ${method}`); + }); + + await executeSlashCommand( + { request } as unknown as GatewayBrowserClient, + "global", + "compact", + "", + { agentId: "work" }, + ); + + expect(request).toHaveBeenCalledWith("sessions.compact", { + key: "global", + agentId: "work", + }); + }); + it("uses the local model catalog to qualify raw /model overrides when the patch response omits provider", async () => { const request = vi.fn(async (method: string, _payload?: unknown) => { if (method === "sessions.patch") { @@ -774,6 +822,65 @@ describe("executeSlashCommand /steer (soft inject)", () => { expect(chatSend.payload.deliver).toBe(false); }); + it("passes selected-agent scope when steering the selected global session", async () => { + const request = vi.fn(async (method: string, _payload?: unknown) => { + if (method === "sessions.list") { + return { sessions: [row("global", { status: "running" })] }; + } + if (method === "chat.send") { + return { status: "started", runId: "run-global", messageSeq: 2 }; + } + throw new Error(`unexpected method: ${method}`); + }); + + const result = await executeSlashCommand( + { request } as unknown as GatewayBrowserClient, + "global", + "steer", + "try a different approach", + { agentId: "work" }, + ); + + expect(result.content).toBe("Steered."); + expect(request).toHaveBeenCalledWith("sessions.list", { agentId: "work" }); + const chatSend = requireRequestCall(request, "chat.send"); + expect(chatSend.payload).toMatchObject({ + sessionKey: "global", + agentId: "work", + message: "try a different approach", + deliver: false, + }); + }); + + it("passes selected-agent scope when steering a selected-global alias", async () => { + const request = vi.fn(async (method: string, _payload?: unknown) => { + if (method === "sessions.list") { + return { sessions: [row("global", { status: "running" })] }; + } + if (method === "chat.send") { + return { status: "started", runId: "run-global", messageSeq: 2 }; + } + throw new Error(`unexpected method: ${method}`); + }); + + const result = await executeSlashCommand( + { request } as unknown as GatewayBrowserClient, + "agent:work:main", + "steer", + "try the alias", + ); + + expect(result.content).toBe("Steered."); + expect(request).toHaveBeenCalledWith("sessions.list", { agentId: "work" }); + const chatSend = requireRequestCall(request, "chat.send"); + expect(chatSend.payload).toMatchObject({ + sessionKey: "agent:work:main", + agentId: "work", + message: "try the alias", + deliver: false, + }); + }); + it("uses cached sessions to avoid an extra sessions.list round trip", async () => { const request = vi.fn(async (method: string, _payload?: unknown) => { if (method === "chat.send") { @@ -976,6 +1083,31 @@ describe("executeSlashCommand /redirect (hard kill-and-restart)", () => { }); }); + it("passes selected-agent scope when redirecting the selected global session", async () => { + const request = vi.fn(async (method: string, _payload?: unknown) => { + if (method === "sessions.steer") { + return { status: "started", runId: "run-global", messageSeq: 2 }; + } + throw new Error(`unexpected method: ${method}`); + }); + + const result = await executeSlashCommand( + { request } as unknown as GatewayBrowserClient, + "global", + "redirect", + "start over", + { agentId: "work" }, + ); + + expect(result.content).toBe("Redirected."); + expect(result.trackRunId).toBe("run-global"); + expect(request).toHaveBeenCalledWith("sessions.steer", { + key: "global", + agentId: "work", + message: "start over", + }); + }); + it("treats subagent-looking redirect prefixes as current-session message text", async () => { const request = vi.fn(async (method: string, _payload?: unknown) => { if (method === "sessions.steer") { diff --git a/ui/src/ui/chat/slash-command-executor.ts b/ui/src/ui/chat/slash-command-executor.ts index 113bb2a86d5c..8ee6867870da 100644 --- a/ui/src/ui/chat/slash-command-executor.ts +++ b/ui/src/ui/chat/slash-command-executor.ts @@ -57,6 +57,7 @@ export type SlashCommandContext = { chatModelCatalog?: ModelCatalogEntry[]; modelCatalog?: ModelCatalogEntry[]; sessionsResult?: SessionsListResult | null; + agentId?: string; }; function normalizeVerboseLevel(raw?: string | null): "off" | "on" | "full" | undefined { @@ -105,15 +106,15 @@ export async function executeSlashCommand( case "focus": return { content: "Toggled focus mode.", action: "toggle-focus" }; case "compact": - return await executeCompact(client, sessionKey); + return await executeCompact(client, sessionKey, context); case "model": return await executeModel(client, sessionKey, args, context); case "think": - return await executeThink(client, sessionKey, args); + return await executeThink(client, sessionKey, args, context); case "fast": - return await executeFast(client, sessionKey, args); + return await executeFast(client, sessionKey, args, context); case "verbose": - return await executeVerbose(client, sessionKey, args); + return await executeVerbose(client, sessionKey, args, context); case "export-session": return { content: "Exporting session...", action: "export" }; case "usage": @@ -123,7 +124,7 @@ export async function executeSlashCommand( case "steer": return await executeSteer(client, sessionKey, args, context); case "redirect": - return await executeRedirect(client, sessionKey, args); + return await executeRedirect(client, sessionKey, args, context); default: return { content: `Unknown command: \`/${commandName}\`` }; } @@ -153,13 +154,14 @@ function executeHelp(): SlashCommandResult { async function executeCompact( client: GatewayBrowserClient, sessionKey: string, + context: SlashCommandContext, ): Promise { try { const result = await client.request<{ compacted?: boolean; reason?: string; result?: { tokensBefore?: number; tokensAfter?: number }; - }>("sessions.compact", { key: sessionKey }); + }>("sessions.compact", { key: sessionKey, ...selectedGlobalScope(sessionKey, context) }); if (result?.compacted) { const before = result.result?.tokensBefore; const after = result.result?.tokensAfter; @@ -214,6 +216,7 @@ async function executeModel( const [patched, resolvedModelCatalog] = await Promise.all([ client.request("sessions.patch", { key: sessionKey, + ...selectedGlobalScope(sessionKey, context), model: requestedModel, }), modelCatalog @@ -251,6 +254,7 @@ async function executeThink( client: GatewayBrowserClient, sessionKey: string, args: string, + context: SlashCommandContext, ): Promise { const rawLevel = args.trim(); @@ -270,7 +274,11 @@ async function executeThink( if (isSessionDefaultDirectiveValue(rawLevel)) { try { - await client.request("sessions.patch", { key: sessionKey, thinkingLevel: null }); + await client.request("sessions.patch", { + key: sessionKey, + ...selectedGlobalScope(sessionKey, context), + thinkingLevel: null, + }); return { content: "Thinking level reset to default.", action: "refresh", @@ -293,7 +301,11 @@ async function executeThink( content: `Unsupported thinking level "${rawLevel}" for this model. Valid levels: ${formatThinkingCommandOptionsForSession(session, defaults)}.`, }; } - await client.request("sessions.patch", { key: sessionKey, thinkingLevel: level }); + await client.request("sessions.patch", { + key: sessionKey, + ...selectedGlobalScope(sessionKey, context), + thinkingLevel: level, + }); return { content: `Thinking level set to **${level}**.`, action: "refresh", @@ -307,6 +319,7 @@ async function executeVerbose( client: GatewayBrowserClient, sessionKey: string, args: string, + context: SlashCommandContext, ): Promise { const rawLevel = args.trim(); @@ -332,7 +345,11 @@ async function executeVerbose( } try { - await client.request("sessions.patch", { key: sessionKey, verboseLevel: level }); + await client.request("sessions.patch", { + key: sessionKey, + ...selectedGlobalScope(sessionKey, context), + verboseLevel: level, + }); return { content: `Verbose mode set to **${level}**.`, action: "refresh", @@ -346,6 +363,7 @@ async function executeFast( client: GatewayBrowserClient, sessionKey: string, args: string, + context: SlashCommandContext, ): Promise { const rawMode = normalizeLowercaseStringOrEmpty(args); @@ -365,7 +383,11 @@ async function executeFast( if (isSessionDefaultDirectiveValue(rawMode)) { try { - await client.request("sessions.patch", { key: sessionKey, fastMode: null }); + await client.request("sessions.patch", { + key: sessionKey, + ...selectedGlobalScope(sessionKey, context), + fastMode: null, + }); return { content: "Fast mode reset to default.", action: "refresh", @@ -382,7 +404,11 @@ async function executeFast( } try { - await client.request("sessions.patch", { key: sessionKey, fastMode: rawMode === "on" }); + await client.request("sessions.patch", { + key: sessionKey, + ...selectedGlobalScope(sessionKey, context), + fastMode: rawMode === "on", + }); return { content: `Fast mode ${rawMode === "on" ? "enabled" : "disabled"}.`, action: "refresh", @@ -464,11 +490,34 @@ function normalizeSessionKey(key?: string | null): string | undefined { return normalizeOptionalLowercaseString(key); } +function selectedGlobalScope( + sessionKey: string, + context: SlashCommandContext, +): { agentId?: string } { + const normalizedSessionKey = normalizeSessionKey(sessionKey); + const parsed = parseAgentSessionKey(normalizedSessionKey ?? ""); + const aliasAgentId = + parsed && + parsed.agentId !== DEFAULT_AGENT_ID && + (parsed.rest === DEFAULT_MAIN_KEY || parsed.rest === "global") + ? parsed.agentId + : undefined; + const agentId = aliasAgentId ?? normalizeOptionalLowercaseString(context.agentId); + return (normalizedSessionKey === "global" || aliasAgentId) && agentId ? { agentId } : {}; +} + function resolveEquivalentSessionKeys( currentSessionKey: string, currentAgentId: string | undefined, ): Set { const keys = new Set([currentSessionKey]); + if (currentAgentId && currentAgentId !== DEFAULT_AGENT_ID) { + const agentMainKey = `agent:${currentAgentId}:${DEFAULT_MAIN_KEY}`; + const agentGlobalKey = `agent:${currentAgentId}:global`; + if (currentSessionKey === agentMainKey || currentSessionKey === agentGlobalKey) { + keys.add("global"); + } + } if (currentAgentId === DEFAULT_AGENT_ID) { const canonicalDefaultMain = `agent:${DEFAULT_AGENT_ID}:main`; if (currentSessionKey === DEFAULT_MAIN_KEY) { @@ -694,7 +743,11 @@ async function executeSteer( }; } const sessions = - context.sessionsResult ?? (await client.request("sessions.list", {})); + context.sessionsResult ?? + (await client.request( + "sessions.list", + selectedGlobalScope(sessionKey, context), + )); const targetSession = resolveCurrentSession(sessions, resolved.key); if (!isActiveSteerSession(targetSession)) { return { @@ -703,6 +756,7 @@ async function executeSteer( } await client.request("chat.send", { sessionKey: resolved.key, + ...selectedGlobalScope(resolved.key, context), message: resolved.message, deliver: false, idempotencyKey: generateUUID(), @@ -721,6 +775,7 @@ async function executeRedirect( client: GatewayBrowserClient, sessionKey: string, args: string, + context: SlashCommandContext, ): Promise { try { const resolved = await resolveSteerTarget(sessionKey, args); @@ -731,6 +786,7 @@ async function executeRedirect( } const resp = await client.request<{ runId?: string }>("sessions.steer", { key: resolved.key, + ...selectedGlobalScope(resolved.key, context), message: resolved.message, }); const runId = typeof resp?.runId === "string" ? resp.runId : undefined; diff --git a/ui/src/ui/controllers/chat.test.ts b/ui/src/ui/controllers/chat.test.ts index 958e95b39b79..d24907ddd381 100644 --- a/ui/src/ui/controllers/chat.test.ts +++ b/ui/src/ui/controllers/chat.test.ts @@ -8,6 +8,7 @@ import { abortChatRun, handleChatEvent, loadChatHistory, + requestChatSend, sendChatMessage, type ChatEventPayload, type ChatState, @@ -113,6 +114,77 @@ describe("handleChatEvent", () => { expect(handleChatEvent(state, payload)).toBe(null); }); + it("ignores selected-agent global events for another agent", () => { + const state = createState({ + sessionKey: "global", + assistantAgentId: "work", + }); + const payload: ChatEventPayload = { + runId: "run-main-global", + sessionKey: "global", + agentId: "main", + state: "final", + }; + + expect(handleChatEvent(state, payload)).toBe(null); + expect(state.chatRunId).toBeNull(); + }); + + it("ignores canonical global events for another selected agent main alias", () => { + const state = createState({ + sessionKey: "agent:work:main", + }); + const payload: ChatEventPayload = { + runId: "run-main-global", + sessionKey: "global", + agentId: "main", + state: "final", + }; + + expect(handleChatEvent(state, payload)).toBe(null); + expect(state.chatRunId).toBeNull(); + }); + + it("treats unscoped global events as default-agent events only", () => { + const state = createState({ + sessionKey: "global", + assistantAgentId: "work", + agentsList: { defaultId: "main" }, + }); + const payload: ChatEventPayload = { + runId: "run-default-global", + sessionKey: "global", + state: "final", + }; + + expect(handleChatEvent(state, payload)).toBe(null); + expect(state.chatRunId).toBeNull(); + }); + + it("adopts canonical global deltas for the selected agent main alias", () => { + const state = createState({ + sessionKey: "agent:work:main", + chatRunId: null, + chatStream: null, + chatStreamStartedAt: null, + }); + const payload: ChatEventPayload = { + runId: "run-work-global", + sessionKey: "global", + agentId: "work", + state: "delta", + message: { + role: "assistant", + content: [{ type: "text", text: "Work reply" }], + }, + }; + + expect(handleChatEvent(state, payload)).toBe("delta"); + expect(state.chatRunId).toBe("run-work-global"); + expect(state.chatStream).toBe("Work reply"); + expect(state.chatStreamStartedAt).toEqual(expect.any(Number)); + }); + it("accepts delta events for the active run when gateway emits a canonical session key", () => { const state = createState({ sessionKey: "main", @@ -994,6 +1066,44 @@ describe("loadChatHistory filtering", () => { expect(state.chatMessages).toEqual(messages); }); + + it("omits literal global agentId until selected/default agent is known", async () => { + const request = vi.fn().mockResolvedValue({ messages: [] }); + const state = createState({ + sessionKey: "global", + client: { request } as unknown as ChatState["client"], + connected: true, + }); + + await loadChatHistory(state); + + expect(request).toHaveBeenCalledWith( + "chat.history", + expect.not.objectContaining({ agentId: expect.anything() }), + ); + }); + + it("uses hello default agent for literal global history before agents list loads", async () => { + const request = vi.fn().mockResolvedValue({ messages: [] }); + const state = createState({ + sessionKey: "global", + hello: { + type: "hello-ok", + protocol: 4, + auth: { role: "operator", scopes: [] }, + snapshot: { sessionDefaults: { defaultAgentId: "ops" } }, + }, + client: { request } as unknown as ChatState["client"], + connected: true, + }); + + await loadChatHistory(state); + + expect(request).toHaveBeenCalledWith( + "chat.history", + expect.objectContaining({ sessionKey: "global", agentId: "ops" }), + ); + }); }); describe("sendChatMessage", () => { @@ -1045,6 +1155,81 @@ describe("sendChatMessage", () => { expect(sendParams.message).toBe("continue"); }); + it("does not reuse another global agent's visible session id for queued sends", async () => { + const request = vi.fn().mockResolvedValue({ runId: "run-work", status: "started" }); + const state = createState({ + assistantAgentId: "main", + currentSessionId: "session-main-visible", + sessionKey: "global", + connected: true, + client: { request } as unknown as ChatState["client"], + }); + + const result = await requestChatSend(state, { + message: "queued", + runId: "run-work", + sessionKey: "global", + agentId: "work", + }); + + expect(result).toEqual({ runId: "run-work", status: "started" }); + expect(request).toHaveBeenCalledWith( + "chat.send", + expect.objectContaining({ + sessionKey: "global", + agentId: "work", + message: "queued", + idempotencyKey: "run-work", + }), + ); + const sendParams = requireRecord(request.mock.calls[0]?.[1]); + expect(sendParams.sessionId).toBeUndefined(); + }); + + it("omits literal global send agentId until selected/default agent is known", async () => { + const request = vi.fn().mockResolvedValue({ runId: "run-global", status: "started" }); + const state = createState({ + sessionKey: "global", + connected: true, + client: { request } as unknown as ChatState["client"], + }); + + await requestChatSend(state, { + message: "queued", + runId: "run-global", + }); + + expect(request).toHaveBeenCalledWith( + "chat.send", + expect.not.objectContaining({ agentId: expect.anything() }), + ); + }); + + it("uses hello default agent for literal global sends before agents list loads", async () => { + const request = vi.fn().mockResolvedValue({ runId: "run-global", status: "started" }); + const state = createState({ + sessionKey: "global", + hello: { + type: "hello-ok", + protocol: 4, + auth: { role: "operator", scopes: [] }, + snapshot: { sessionDefaults: { defaultAgentId: "ops" } }, + }, + connected: true, + client: { request } as unknown as ChatState["client"], + }); + + await requestChatSend(state, { + message: "queued", + runId: "run-global", + }); + + expect(request).toHaveBeenCalledWith( + "chat.send", + expect.objectContaining({ sessionKey: "global", agentId: "ops" }), + ); + }); + it("adopts the run id and terminal status from the chat.send ack", async () => { const request = vi.fn().mockResolvedValue({ runId: "gateway-complete-run", status: "ok" }); const state = createState({ @@ -1555,4 +1740,36 @@ describe("loadChatHistory retry handling", () => { ]); expect(state.chatThinkingLevel).toBe("low"); }); + + it("ignores stale global history responses after switching selected agents", async () => { + const workRequest = createDeferred<{ messages: Array; thinkingLevel?: string }>(); + const request = vi.fn((_method: string, params?: { agentId?: string; sessionKey?: string }) => { + if (params?.sessionKey === "global" && params.agentId === "work") { + return workRequest.promise; + } + throw new Error(`Unexpected request: ${JSON.stringify(params)}`); + }); + const state = createState({ + connected: true, + client: { request } as unknown as ChatState["client"], + sessionKey: "global", + assistantAgentId: "work", + agentsList: { defaultId: "main" }, + chatMessages: [{ role: "assistant", content: [{ type: "text", text: "visible old" }] }], + }); + + const load = loadChatHistory(state); + state.assistantAgentId = "main"; + workRequest.resolve({ + messages: [{ role: "assistant", content: [{ type: "text", text: "work history" }] }], + thinkingLevel: "high", + }); + await load; + + expect(state.chatLoading).toBe(false); + expect(state.chatMessages).toEqual([ + { role: "assistant", content: [{ type: "text", text: "visible old" }] }, + ]); + expect(state.chatThinkingLevel).toBeNull(); + }); }); diff --git a/ui/src/ui/controllers/chat.ts b/ui/src/ui/controllers/chat.ts index 05e099703334..602cfc8bf228 100644 --- a/ui/src/ui/controllers/chat.ts +++ b/ui/src/ui/controllers/chat.ts @@ -10,8 +10,12 @@ import { import { extractText } from "../chat/message-extract.ts"; import { reconcileChatRunLifecycle } from "../chat/run-lifecycle.ts"; import { formatConnectError } from "../connect-error.ts"; -import { GatewayRequestError, type GatewayBrowserClient } from "../gateway.ts"; -import { areUiSessionKeysEquivalent } from "../session-key.ts"; +import { GatewayRequestError, type GatewayBrowserClient, type GatewayHelloOk } from "../gateway.ts"; +import { + areUiSessionKeysEquivalent, + normalizeAgentId, + parseAgentSessionKey, +} from "../session-key.ts"; import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts"; import type { ChatAttachment } from "../ui-types.ts"; import { generateUUID } from "../uuid.ts"; @@ -45,8 +49,12 @@ function shouldApplyChatHistoryResult( state: ChatState, version: number, sessionKey: string, + agentId?: string | null, ): boolean { - return isLatestChatHistoryRequest(state, version) && state.sessionKey === sessionKey; + if (!isLatestChatHistoryRequest(state, version) || state.sessionKey !== sessionKey) { + return false; + } + return !isSelectedGlobalEventSessionKey(sessionKey) || resolveSelectedAgentId(state) === agentId; } function isSilentReplyStream(text: string): boolean { @@ -280,16 +288,100 @@ export type ChatState = { chatStreamStartedAt: number | null; lastError: string | null; resetChatInputHistoryNavigation?: () => void; + assistantAgentId?: string | null; + agentsList?: { defaultId?: string | null } | null; + hello?: GatewayHelloOk | null; }; export type ChatEventPayload = { runId?: string; sessionKey: string; + agentId?: string; state: "delta" | "final" | "aborted" | "error"; message?: unknown; errorMessage?: string; }; +function isGlobalSessionKey(sessionKey: string | undefined | null): boolean { + const normalized = normalizeLowercaseStringOrEmpty(sessionKey); + return normalized === "global"; +} + +function isSelectedGlobalEventSessionKey(sessionKey: string | undefined | null): boolean { + if (isGlobalSessionKey(sessionKey)) { + return true; + } + const parsed = parseAgentSessionKey(sessionKey); + return normalizeLowercaseStringOrEmpty(parsed?.rest) === "main"; +} + +function resolveSelectedAgentId(state: ChatState): string | undefined { + const parsed = parseAgentSessionKey(state.sessionKey); + if (parsed?.agentId) { + return normalizeAgentId(parsed.agentId); + } + const snapshot = state.hello?.snapshot as + | { sessionDefaults?: { defaultAgentId?: string } } + | undefined; + const assistantAgentId = + typeof state.assistantAgentId === "string" && state.assistantAgentId.trim() + ? state.assistantAgentId + : undefined; + const defaultAgentId = + typeof state.agentsList?.defaultId === "string" && state.agentsList.defaultId.trim() + ? state.agentsList.defaultId + : undefined; + const helloDefaultAgentId = + typeof snapshot?.sessionDefaults?.defaultAgentId === "string" && + snapshot.sessionDefaults.defaultAgentId.trim() + ? snapshot.sessionDefaults.defaultAgentId + : undefined; + const selectedAgentId = assistantAgentId ?? defaultAgentId ?? helloDefaultAgentId; + return selectedAgentId ? normalizeAgentId(selectedAgentId) : undefined; +} + +function resolveDefaultAgentId(state: ChatState): string | undefined { + const snapshot = state.hello?.snapshot as + | { sessionDefaults?: { defaultAgentId?: string } } + | undefined; + const agentId = + typeof state.agentsList?.defaultId === "string" && state.agentsList.defaultId.trim() + ? state.agentsList.defaultId + : typeof snapshot?.sessionDefaults?.defaultAgentId === "string" && + snapshot.sessionDefaults.defaultAgentId.trim() + ? snapshot.sessionDefaults.defaultAgentId + : undefined; + return agentId ? normalizeAgentId(agentId) : undefined; +} + +function chatEventAgentScopeMatches(state: ChatState, payload: ChatEventPayload): boolean { + if ( + !isSelectedGlobalEventSessionKey(state.sessionKey) || + !isGlobalSessionKey(payload.sessionKey) + ) { + return true; + } + const payloadAgentId = + typeof payload.agentId === "string" && payload.agentId.trim() + ? normalizeAgentId(payload.agentId) + : undefined; + const selectedAgentId = resolveSelectedAgentId(state); + return payloadAgentId + ? selectedAgentId !== undefined && payloadAgentId === selectedAgentId + : selectedAgentId === undefined || selectedAgentId === resolveDefaultAgentId(state); +} + +function chatEventSessionMatches(state: ChatState, payload: ChatEventPayload): boolean { + if (areUiSessionKeysEquivalent(payload.sessionKey, state.sessionKey)) { + return chatEventAgentScopeMatches(state, payload); + } + return ( + isGlobalSessionKey(payload.sessionKey) && + isSelectedGlobalEventSessionKey(state.sessionKey) && + chatEventAgentScopeMatches(state, payload) + ); +} + function maybeResetToolStream(state: ChatState) { const toolHost = state as ChatState & Partial[0]>; if ( @@ -308,6 +400,9 @@ export async function loadChatHistory(state: ChatState) { } const sessionKey = state.sessionKey; const requestVersion = beginChatHistoryRequest(state); + const requestAgentId = isSelectedGlobalEventSessionKey(sessionKey) + ? resolveSelectedAgentId(state) + : null; const startedAt = Date.now(); const previousMessages = state.chatMessages; // Any pending input-history snapshot becomes invalid once we start reloading transcript state. @@ -324,12 +419,13 @@ export async function loadChatHistory(state: ChatState) { thinkingLevel?: string; }>("chat.history", { sessionKey, + ...(requestAgentId ? { agentId: requestAgentId } : {}), limit: CHAT_HISTORY_REQUEST_LIMIT, maxChars: CHAT_HISTORY_REQUEST_MAX_CHARS, }); break; } catch (err) { - if (!shouldApplyChatHistoryResult(state, requestVersion, sessionKey)) { + if (!shouldApplyChatHistoryResult(state, requestVersion, sessionKey, requestAgentId)) { return; } const withinStartupRetryWindow = @@ -344,7 +440,7 @@ export async function loadChatHistory(state: ChatState) { throw err; } } - if (!shouldApplyChatHistoryResult(state, requestVersion, sessionKey)) { + if (!shouldApplyChatHistoryResult(state, requestVersion, sessionKey, requestAgentId)) { return; } const messages = Array.isArray(res.messages) ? res.messages : []; @@ -359,7 +455,7 @@ export async function loadChatHistory(state: ChatState) { state.chatStream = null; state.chatStreamStartedAt = null; } catch (err) { - if (!shouldApplyChatHistoryResult(state, requestVersion, sessionKey)) { + if (!shouldApplyChatHistoryResult(state, requestVersion, sessionKey, requestAgentId)) { return; } if (isMissingOperatorReadScopeError(err)) { @@ -442,18 +538,25 @@ export async function requestChatSend( attachments?: ChatAttachment[]; runId: string; sessionKey?: string; + agentId?: string; }, ): Promise { const sessionKey = params.sessionKey ?? state.sessionKey; + const selectedAgentId = params.agentId + ? normalizeAgentId(params.agentId) + : resolveSelectedAgentId(state); const currentSessionId = state.currentSessionId; - const sessionId = + const canReuseCurrentSessionId = sessionKey === state.sessionKey && - typeof currentSessionId === "string" && - currentSessionId.trim() + (!isGlobalSessionKey(sessionKey) || + (selectedAgentId !== undefined && selectedAgentId === resolveSelectedAgentId(state))); + const sessionId = + canReuseCurrentSessionId && typeof currentSessionId === "string" && currentSessionId.trim() ? currentSessionId.trim() : undefined; const payload = await state.client!.request("chat.send", { sessionKey, + ...(isGlobalSessionKey(sessionKey) && selectedAgentId ? { agentId: selectedAgentId } : {}), ...(sessionId ? { sessionId } : {}), message: params.message, deliver: false, @@ -687,7 +790,22 @@ export async function abortChatRun(state: ChatState): Promise { try { await state.client.request( "chat.abort", - runId ? { sessionKey: state.sessionKey, runId } : { sessionKey: state.sessionKey }, + runId + ? { + sessionKey: state.sessionKey, + ...(() => { + const agentId = resolveSelectedAgentId(state); + return isGlobalSessionKey(state.sessionKey) && agentId ? { agentId } : {}; + })(), + runId, + } + : { + sessionKey: state.sessionKey, + ...(() => { + const agentId = resolveSelectedAgentId(state); + return isGlobalSessionKey(state.sessionKey) && agentId ? { agentId } : {}; + })(), + }, ); return true; } catch (err) { @@ -700,7 +818,7 @@ export function handleChatEvent(state: ChatState, payload?: ChatEventPayload) { if (!payload) { return null; } - const sessionMatches = areUiSessionKeysEquivalent(payload.sessionKey, state.sessionKey); + const sessionMatches = chatEventSessionMatches(state, payload); const activeRunMatches = state.chatRunId !== null && typeof payload.runId === "string" && diff --git a/ui/src/ui/controllers/sessions.test.ts b/ui/src/ui/controllers/sessions.test.ts index b31b11ace000..ddb5548c8397 100644 --- a/ui/src/ui/controllers/sessions.test.ts +++ b/ui/src/ui/controllers/sessions.test.ts @@ -2,12 +2,16 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { isSessionRunActive } from "../session-run-state.ts"; import { applySessionsChangedEvent, + branchSessionFromCheckpoint, createSessionAndRefresh, deleteSessionsAndRefresh, loadSessions, + patchSession, parseSessionsFilterInteger, + restoreSessionFromCheckpoint, subscribeSessions, syncSelectedSessionMessageSubscription, + toggleSessionCompactionCheckpoints, type SessionsState, } from "./sessions.ts"; @@ -134,6 +138,80 @@ describe("syncSelectedSessionMessageSubscription", () => { expect(state.chatSessionMessageSubscriptionKey).toBe("agent:main:main"); }); + it("subscribes selected global message streams with the selected agent", async () => { + const request = vi.fn(async () => ({ key: "global" })); + const state = createState(request, { + sessionKey: "global", + assistantAgentId: "work", + } as Partial) as SessionsState & { sessionKey: string }; + + await syncSelectedSessionMessageSubscription(state); + + expect(request).toHaveBeenCalledWith("sessions.messages.subscribe", { + key: "global", + agentId: "work", + }); + expect(state.chatSessionMessageSubscriptionAgentId).toBe("work"); + }); + + it("keeps agent-scoped global alias subscriptions scoped for unsubscribe", async () => { + const request = vi.fn(async (method: string) => + method === "sessions.messages.subscribe" ? { key: "global" } : { subscribed: false }, + ); + const state = createState(request, { + sessionKey: "agent:work:main", + assistantAgentId: "main", + sessionsResult: { + ts: 1, + path: "/tmp/sessions.json", + count: 2, + sessions: [ + { key: "agent:work:main", kind: "global", updatedAt: 2 }, + { key: "agent:ops:main", kind: "global", updatedAt: 1 }, + ], + defaults: { modelProvider: null, model: null, contextTokens: null }, + totalCount: 2, + limit: 50, + offset: 0, + hasMore: false, + }, + } as Partial) as SessionsState & { sessionKey: string }; + + await syncSelectedSessionMessageSubscription(state); + state.sessionKey = "agent:ops:main"; + await syncSelectedSessionMessageSubscription(state); + + expect(request).toHaveBeenNthCalledWith(1, "sessions.messages.subscribe", { + key: "agent:work:main", + agentId: "work", + }); + expect(request).toHaveBeenNthCalledWith(2, "sessions.messages.unsubscribe", { + key: "global", + agentId: "work", + }); + expect(request).toHaveBeenNthCalledWith(3, "sessions.messages.subscribe", { + key: "agent:ops:main", + agentId: "ops", + }); + expect(state.chatSessionMessageSubscriptionAgentId).toBe("ops"); + }); + + it("uses the hello default agent for global subscriptions before agents load", async () => { + const request = vi.fn(async () => ({ key: "global" })); + const state = createState(request, { + sessionKey: "global", + hello: { snapshot: { sessionDefaults: { defaultAgentId: "ops" } } }, + } as Partial) as SessionsState & { sessionKey: string }; + + await syncSelectedSessionMessageSubscription(state); + + expect(request).toHaveBeenCalledWith("sessions.messages.subscribe", { + key: "global", + agentId: "ops", + }); + expect(state.chatSessionMessageSubscriptionAgentId).toBe("ops"); + }); + it("ignores stale subscription completions after the selected session changes", async () => { const firstSubscribe = createDeferred<{ key: string }>(); const request = vi.fn(async (method: string, params?: unknown) => { @@ -172,6 +250,56 @@ describe("syncSelectedSessionMessageSubscription", () => { key: "agent:main:first", }); }); + + it("cleans up stale selected-global subscriptions when only the selected agent changes", async () => { + const firstSubscribe = createDeferred<{ key: string }>(); + const request = vi.fn(async (method: string, params?: unknown) => { + const record = params as { key?: string; agentId?: string } | undefined; + if ( + method === "sessions.messages.subscribe" && + record?.key === "global" && + record.agentId === "work" + ) { + return await firstSubscribe.promise; + } + if ( + method === "sessions.messages.subscribe" && + record?.key === "global" && + record.agentId === "main" + ) { + return { key: "global" }; + } + if (method === "sessions.messages.unsubscribe") { + return { subscribed: false, key: record?.key }; + } + throw new Error(`unexpected request: ${method} ${String(record?.key)} ${record?.agentId}`); + }); + const state = createState(request, { + sessionKey: "global", + assistantAgentId: "work", + } as Partial) as SessionsState & { sessionKey: string }; + + const firstSync = syncSelectedSessionMessageSubscription(state); + expect(request).toHaveBeenCalledWith("sessions.messages.subscribe", { + key: "global", + agentId: "work", + }); + + state.assistantAgentId = "main"; + await syncSelectedSessionMessageSubscription(state); + expect(state.chatSessionMessageSubscriptionKey).toBe("global"); + expect(state.chatSessionMessageSubscriptionAgentId).toBe("main"); + + firstSubscribe.resolve({ key: "global" }); + await firstSync; + + expect(state.chatSessionMessageSubscriptionKey).toBe("global"); + expect(state.chatSessionMessageSubscriptionAgentId).toBe("main"); + expect(request).toHaveBeenCalledWith("sessions.messages.unsubscribe", { + key: "global", + agentId: "work", + }); + }); }); describe("createSessionAndRefresh", () => { @@ -275,6 +403,38 @@ describe("deleteSessionsAndRefresh", () => { expect(state.sessionsLoading).toBe(false); }); + it("passes selected agent scope for global deletes", async () => { + const request = vi.fn(async (method: string) => { + if (method === "sessions.delete") { + return { ok: true }; + } + if (method === "sessions.list") { + return undefined; + } + throw new Error(`unexpected method: ${method}`); + }); + const state = createState(request, { + assistantAgentId: "work", + agentsList: { defaultId: "main" }, + }); + vi.spyOn(window, "confirm").mockReturnValue(true); + + const deleted = await deleteSessionsAndRefresh(state, ["global"]); + + expect(deleted).toEqual(["global"]); + expect(request).toHaveBeenNthCalledWith(1, "sessions.delete", { + key: "global", + agentId: "work", + deleteTranscript: true, + }); + expect(request).toHaveBeenNthCalledWith(2, "sessions.list", { + includeGlobal: true, + includeUnknown: true, + configuredAgentsOnly: true, + agentId: "work", + }); + }); + it("returns empty array when user cancels", async () => { const request = vi.fn(async () => undefined); const state = createState(request); @@ -371,6 +531,30 @@ describe("deleteSessionsAndRefresh", () => { }); }); +describe("patchSession", () => { + it("passes selected agent scope for global patches", async () => { + const request = vi.fn(async () => ({ ok: true })); + const state = createState(request, { + assistantAgentId: "work", + agentsList: { defaultId: "main" }, + }); + + await patchSession(state, "global", { fastMode: true }); + + expect(request).toHaveBeenNthCalledWith(1, "sessions.patch", { + key: "global", + agentId: "work", + fastMode: true, + }); + expect(request).toHaveBeenNthCalledWith(2, "sessions.list", { + includeGlobal: true, + includeUnknown: true, + configuredAgentsOnly: true, + agentId: "work", + }); + }); +}); + describe("loadSessions", () => { it("hides explicitly archived sessions by default", async () => { const request = vi.fn(async (method: string) => { @@ -971,6 +1155,72 @@ describe("loadSessions", () => { state.sessionsCheckpointItemsByKey["agent:main:main"]?.map((item) => item.checkpointId), ).toEqual(["checkpoint-new"]); }); + + it("requests selected global checkpoints with the selected agent", async () => { + const request = vi.fn(async (method: string) => { + if (method === "sessions.compaction.list") { + return { ok: true, key: "global", checkpoints: [] }; + } + throw new Error(`unexpected method: ${method}`); + }); + const state = createState(request, { + sessionKey: "global", + assistantAgentId: "work", + } as Partial); + + await toggleSessionCompactionCheckpoints(state, "global"); + + expect(request).toHaveBeenCalledWith("sessions.compaction.list", { + key: "global", + agentId: "work", + }); + }); + + it("sends selected global agent scope for checkpoint branch and restore", async () => { + vi.spyOn(window, "confirm").mockReturnValue(true); + const request = vi.fn(async (method: string) => { + if (method === "sessions.list") { + return { ts: 1, path: "(multiple)", count: 0, defaults: {}, sessions: [] }; + } + if (method === "sessions.compaction.branch") { + return { ok: true, sourceKey: "global", key: "agent:work:dashboard:1" }; + } + if (method === "sessions.compaction.restore") { + return { ok: true, key: "global" }; + } + throw new Error(`unexpected method: ${method}`); + }); + const state = createState(request, { + sessionKey: "global", + assistantAgentId: "work", + } as Partial); + + await branchSessionFromCheckpoint(state, "global", "checkpoint-1"); + await restoreSessionFromCheckpoint(state, "global", "checkpoint-1"); + + expect(request).toHaveBeenNthCalledWith(1, "sessions.compaction.branch", { + key: "global", + agentId: "work", + checkpointId: "checkpoint-1", + }); + expect(request).toHaveBeenNthCalledWith(2, "sessions.list", { + includeGlobal: true, + includeUnknown: true, + configuredAgentsOnly: true, + agentId: "work", + }); + expect(request).toHaveBeenNthCalledWith(3, "sessions.compaction.restore", { + key: "global", + agentId: "work", + checkpointId: "checkpoint-1", + }); + expect(request).toHaveBeenNthCalledWith(4, "sessions.list", { + includeGlobal: true, + includeUnknown: true, + configuredAgentsOnly: true, + agentId: "work", + }); + }); }); describe("applySessionsChangedEvent", () => { @@ -1045,6 +1295,132 @@ describe("applySessionsChangedEvent", () => { ]); }); + it("ignores selected-global session events for another agent", () => { + const state = createState(async () => undefined, { + sessionKey: "global", + assistantAgentId: "work", + agentsList: { defaultId: "main" }, + sessionsResult: { + ts: 1, + path: "(multiple)", + count: 1, + defaults: { modelProvider: null, model: null, contextTokens: null }, + sessions: [{ key: "global", kind: "global", updatedAt: 1, status: "done" }], + }, + }); + + const applied = applySessionsChangedEvent(state, { + sessionKey: "global", + agentId: "main", + reason: "send", + ts: 2, + status: "running", + }); + + expect(applied).toEqual({ applied: false }); + expect(state.sessionsResult?.sessions).toEqual([ + { key: "global", kind: "global", updatedAt: 1, status: "done" }, + ]); + }); + + it("applies selected-global session events for the current agent", () => { + const state = createState(async () => undefined, { + sessionKey: "global", + assistantAgentId: "work", + agentsList: { defaultId: "main" }, + sessionsResult: { + ts: 1, + path: "(multiple)", + count: 1, + defaults: { modelProvider: null, model: null, contextTokens: null }, + sessions: [{ key: "global", kind: "global", updatedAt: 1, status: "done" }], + }, + }); + + const applied = applySessionsChangedEvent(state, { + sessionKey: "global", + agentId: "work", + reason: "send", + ts: 2, + status: "running", + }); + + expect(applied).toEqual({ applied: true, change: "updated" }); + expect(state.sessionsResult?.sessions[0]).toEqual( + expect.objectContaining({ key: "global", status: "running" }), + ); + }); + + it("applies goal updates from partial events to existing rows", () => { + const state = createState(async () => undefined, { + sessionsResult: { + ts: 1, + path: "(multiple)", + count: 1, + defaults: { modelProvider: null, model: null, contextTokens: null }, + sessions: [{ key: "agent:main:main", kind: "direct", updatedAt: 1 }], + }, + }); + + const applied = applySessionsChangedEvent(state, { + sessionKey: "agent:main:main", + reason: "goal", + goal: { + objective: "Land the web goal UI", + status: "active", + usage: { totalTokens: 12_345 }, + tokenBudget: 50_000, + }, + ts: 2, + }); + + expect(applied).toEqual({ applied: true, change: "updated" }); + expect(state.sessionsResult?.sessions[0]?.goal).toMatchObject({ + objective: "Land the web goal UI", + status: "active", + tokenBudget: 50_000, + }); + }); + + it("clears goal updates from partial events with explicit null goals", () => { + const state = createState(async () => undefined, { + sessionsResult: { + ts: 1, + path: "(multiple)", + count: 1, + defaults: { modelProvider: null, model: null, contextTokens: null }, + sessions: [ + { + key: "agent:main:main", + kind: "direct", + updatedAt: 1, + goal: { + schemaVersion: 1, + id: "goal-1", + objective: "Land the web goal UI", + status: "active", + createdAt: 1, + updatedAt: 1, + tokenStart: 0, + tokensUsed: 10, + continuationTurns: 0, + }, + }, + ], + }, + }); + + const applied = applySessionsChangedEvent(state, { + sessionKey: "agent:main:main", + reason: "goal", + goal: null, + ts: 2, + }); + + expect(applied).toEqual({ applied: true, change: "updated" }); + expect(state.sessionsResult?.sessions[0]?.goal).toBeUndefined(); + }); + it("drops rows that become explicitly archived while archived sessions are hidden", () => { const state = createState(async () => undefined, { sessionsResult: { diff --git a/ui/src/ui/controllers/sessions.ts b/ui/src/ui/controllers/sessions.ts index d4af0e035b0b..db92b66ca92b 100644 --- a/ui/src/ui/controllers/sessions.ts +++ b/ui/src/ui/controllers/sessions.ts @@ -2,7 +2,8 @@ import { reconcileChatRunFromCurrentSessionRow, type ChatRunUiStatus, } from "../chat/run-lifecycle.ts"; -import type { GatewayBrowserClient } from "../gateway.ts"; +import type { GatewayBrowserClient, GatewayHelloOk } from "../gateway.ts"; +import { normalizeAgentId, parseAgentSessionKey } from "../session-key.ts"; import type { GatewaySessionRow, SessionCompactionCheckpoint, @@ -42,6 +43,10 @@ export type SessionsState = SessionsChatRunState & { sessionsCheckpointErrorByKey: Record; chatSessionMessageSubscriptionKey?: string | null; chatSessionMessageSubscriptionRequestedKey?: string | null; + chatSessionMessageSubscriptionAgentId?: string | null; + assistantAgentId?: string | null; + agentsList?: { defaultId?: string | null } | null; + hello?: GatewayHelloOk | null; }; export type LoadSessionsOverrides = { @@ -90,6 +95,113 @@ function normalizeSubscriptionKey(value: string | null | undefined): string | nu return normalized ? normalized : null; } +function isGlobalSessionKey(value: string | null | undefined): boolean { + return (value ?? "").trim().toLowerCase() === "global"; +} + +function resolveSelectedGlobalAliasAgentId( + state: SessionsState, + key: string | null | undefined, +): string | null { + const parsed = parseAgentSessionKey(key); + if (!parsed?.agentId) { + return null; + } + const rest = parsed.rest.toLowerCase(); + if (rest === "global") { + return normalizeAgentId(parsed.agentId); + } + if (rest !== "main") { + return null; + } + const row = state.sessionsResult?.sessions.find((session) => session.key === key); + return row?.kind === "global" ? normalizeAgentId(parsed.agentId) : null; +} + +function resolveSelectedSessionMessageSubscriptionAgentId( + state: SessionsState, + key: string, +): string | null { + if (isGlobalSessionKey(key)) { + return resolveSelectedGlobalAgentId(state); + } + return resolveSelectedGlobalAliasAgentId(state, key); +} + +function resolveSelectedGlobalAgentId(state: SessionsState): string { + const parsed = parseAgentSessionKey(state.sessionKey); + if (parsed?.agentId) { + return normalizeAgentId(parsed.agentId); + } + const snapshot = state.hello?.snapshot as + | { sessionDefaults?: { defaultAgentId?: string } } + | undefined; + const assistantAgentId = + typeof state.assistantAgentId === "string" && state.assistantAgentId.trim() + ? state.assistantAgentId + : undefined; + const defaultAgentId = + typeof state.agentsList?.defaultId === "string" && state.agentsList.defaultId.trim() + ? state.agentsList.defaultId + : undefined; + const helloDefaultAgentId = + typeof snapshot?.sessionDefaults?.defaultAgentId === "string" && + snapshot.sessionDefaults.defaultAgentId.trim() + ? snapshot.sessionDefaults.defaultAgentId + : undefined; + return normalizeAgentId(assistantAgentId ?? defaultAgentId ?? helloDefaultAgentId ?? "main"); +} + +function resolveDefaultGlobalAgentId(state: SessionsState): string { + const snapshot = state.hello?.snapshot as + | { sessionDefaults?: { defaultAgentId?: string } } + | undefined; + const defaultAgentId = + typeof state.agentsList?.defaultId === "string" && state.agentsList.defaultId.trim() + ? state.agentsList.defaultId + : typeof snapshot?.sessionDefaults?.defaultAgentId === "string" && + snapshot.sessionDefaults.defaultAgentId.trim() + ? snapshot.sessionDefaults.defaultAgentId + : "main"; + return normalizeAgentId(defaultAgentId); +} + +function sessionsChangedGlobalAgentMatches( + state: SessionsState, + payload: Record, + key: string, +): boolean { + if (!isGlobalSessionKey(key)) { + return true; + } + const eventSession = isRecord(payload.session) ? payload.session : null; + const rawAgentId = + (typeof payload.agentId === "string" && payload.agentId.trim()) || + (typeof eventSession?.agentId === "string" && eventSession.agentId.trim()); + const eventAgentId = rawAgentId ? normalizeAgentId(rawAgentId) : null; + const selectedAgentId = resolveSelectedGlobalAgentId(state); + if (eventAgentId) { + return eventAgentId === selectedAgentId; + } + return selectedAgentId === resolveDefaultGlobalAgentId(state); +} + +function buildSelectedSessionMessageSubscriptionParams(state: SessionsState, key: string) { + const agentId = resolveSelectedSessionMessageSubscriptionAgentId(state, key); + return { + key, + ...(agentId ? { agentId } : {}), + }; +} + +function buildSelectedSessionRequestParams(state: SessionsState, key: string) { + const agentId = resolveSelectedSessionMessageSubscriptionAgentId(state, key); + return { + key, + ...(agentId ? { agentId } : {}), + }; +} + function beginSelectedSessionMessageSubscriptionSync(state: SessionsState): number { const key = state as object; const next = (selectedSessionMessageSubscriptionGenerations.get(key) ?? 0) + 1; @@ -99,13 +211,20 @@ function beginSelectedSessionMessageSubscriptionSync(state: SessionsState): numb function isCurrentSelectedSessionMessageSubscriptionSync( state: SessionsState & { sessionKey: string }, - params: { generation: number; client: GatewayBrowserClient; requestedKey: string }, + params: { + generation: number; + client: GatewayBrowserClient; + requestedKey: string; + requestedAgentId?: string | null; + }, ): boolean { return ( selectedSessionMessageSubscriptionGenerations.get(state as object) === params.generation && state.client === params.client && state.connected && - state.sessionKey.trim() === params.requestedKey + state.sessionKey.trim() === params.requestedKey && + resolveSelectedSessionMessageSubscriptionAgentId(state, params.requestedKey) === + (params.requestedAgentId ?? null) ); } @@ -120,9 +239,13 @@ function readSubscribedSessionMessageKey(result: unknown, fallbackKey: string): async function unsubscribeSelectedSessionMessageBestEffort( client: GatewayBrowserClient, key: string, + agentId?: string | null, ): Promise { try { - await client.request("sessions.messages.unsubscribe", { key }); + await client.request("sessions.messages.unsubscribe", { + key, + ...(isGlobalSessionKey(key) && agentId ? { agentId } : {}), + }); } catch { // Best-effort cleanup for stale async subscription completions. } @@ -157,6 +280,7 @@ const SESSION_EVENT_ROW_FIELDS = [ "endedAt", "elevatedLevel", "fastMode", + "goal", "hasActiveRun", "inputTokens", "kind", @@ -331,7 +455,7 @@ async function fetchSessionCompactionCheckpoints(state: SessionsState, key: stri try { const result = await state.client?.request( "sessions.compaction.list", - { key }, + buildSelectedSessionRequestParams(state, key), ); if (result) { state.sessionsCheckpointItemsByKey = { @@ -388,8 +512,14 @@ async function runCompactionMutation( const client = state.client; state.sessionsCheckpointBusyKey = checkpointId; try { - const result = await client.request(method, { key, checkpointId }); - await loadSessions(state); + const result = await client.request(method, { + ...buildSelectedSessionRequestParams(state, key), + checkpointId, + }); + await loadSessions( + state, + isGlobalSessionKey(key) ? { agentId: resolveSelectedGlobalAgentId(state) } : undefined, + ); return result; } catch (err) { state.sessionsError = String(err); @@ -427,6 +557,9 @@ export function applySessionsChangedEvent( if (!key) { return { applied: false }; } + if (!sessionsChangedGlobalAgentMatches(state, payload, key)) { + return { applied: false }; + } const previousRows = state.sessionsResult.sessions; const existingIndex = previousRows.findIndex((row) => row.key === key); @@ -457,11 +590,14 @@ export function applySessionsChangedEvent( }; const mutableNext = nextRow as unknown as Record; for (const field of SESSION_EVENT_ROW_FIELDS) { - if (!hasOwn(source, field)) { + const hasField = hasOwn(source, field); + const hasTopLevelGoalClear = + field === "goal" && hasOwn(payload, "goal") && payload.goal === null; + if (!hasField && !hasTopLevelGoalClear) { continue; } - const value = source[field]; - if (value === undefined) { + const value = hasTopLevelGoalClear ? null : source[field]; + if (value === undefined || (field === "goal" && value === null)) { delete mutableNext[field]; } else { mutableNext[field] = value; @@ -572,11 +708,18 @@ export async function syncSelectedSessionMessageSubscription( ); const previousCanonicalKey = normalizeSubscriptionKey(state.chatSessionMessageSubscriptionKey); const previousSelectedKey = previousRequestedKey ?? previousCanonicalKey; + const nextSubscriptionAgentId = resolveSelectedSessionMessageSubscriptionAgentId(state, nextKey); + const selectedAgentChanged = + nextSubscriptionAgentId !== null && + previousSelectedKey === nextKey && + (state.chatSessionMessageSubscriptionAgentId ?? null) !== nextSubscriptionAgentId; const selectedKeyChanged = previousSelectedKey !== null && previousSelectedKey !== nextKey; - const shouldUnsubscribePrevious = previousCanonicalKey !== null && selectedKeyChanged; + const shouldUnsubscribePrevious = + previousCanonicalKey !== null && (selectedKeyChanged || selectedAgentChanged); const shouldSubscribe = opts?.force === true || selectedKeyChanged || + selectedAgentChanged || previousCanonicalKey === null || previousRequestedKey === null; if (!shouldUnsubscribePrevious && !shouldSubscribe) { @@ -587,28 +730,43 @@ export async function syncSelectedSessionMessageSubscription( generation, client, requestedKey: nextKey, + requestedAgentId: nextSubscriptionAgentId, }); try { if (shouldUnsubscribePrevious && previousCanonicalKey) { - await client.request("sessions.messages.unsubscribe", { key: previousCanonicalKey }); + await client.request("sessions.messages.unsubscribe", { + key: previousCanonicalKey, + ...(isGlobalSessionKey(previousCanonicalKey) && state.chatSessionMessageSubscriptionAgentId + ? { agentId: state.chatSessionMessageSubscriptionAgentId } + : {}), + }); if (isCurrent()) { state.chatSessionMessageSubscriptionKey = null; state.chatSessionMessageSubscriptionRequestedKey = null; + state.chatSessionMessageSubscriptionAgentId = null; } } if (!shouldSubscribe || !isCurrent()) { return; } - const result = await client.request("sessions.messages.subscribe", { key: nextKey }); + const subscriptionParams = buildSelectedSessionMessageSubscriptionParams(state, nextKey); + const result = await client.request("sessions.messages.subscribe", subscriptionParams); const subscribedKey = readSubscribedSessionMessageKey(result, nextKey); + const subscribedAgentId = "agentId" in subscriptionParams ? subscriptionParams.agentId : null; if (!isCurrent()) { - if (normalizeSubscriptionKey(state.chatSessionMessageSubscriptionKey) !== subscribedKey) { - await unsubscribeSelectedSessionMessageBestEffort(client, subscribedKey); + const staleKeyChanged = + normalizeSubscriptionKey(state.chatSessionMessageSubscriptionKey) !== subscribedKey; + const staleAgentChanged = + isGlobalSessionKey(subscribedKey) && + (state.chatSessionMessageSubscriptionAgentId ?? null) !== subscribedAgentId; + if (staleKeyChanged || staleAgentChanged) { + await unsubscribeSelectedSessionMessageBestEffort(client, subscribedKey, subscribedAgentId); } return; } state.chatSessionMessageSubscriptionRequestedKey = nextKey; state.chatSessionMessageSubscriptionKey = subscribedKey; + state.chatSessionMessageSubscriptionAgentId = subscribedAgentId; } catch (err) { if (isCurrent()) { state.sessionsError = String(err); @@ -762,7 +920,10 @@ export async function patchSession( if (!state.client || !state.connected) { return; } - const params: Record = { key }; + const params: Record = { + key, + ...(isGlobalSessionKey(key) ? { agentId: resolveSelectedGlobalAgentId(state) } : {}), + }; for (const field of [ "label", "thinkingLevel", @@ -776,7 +937,10 @@ export async function patchSession( } try { await state.client.request("sessions.patch", params); - await loadSessions(state); + await loadSessions( + state, + isGlobalSessionKey(key) ? { agentId: resolveSelectedGlobalAgentId(state) } : undefined, + ); } catch (err) { state.sessionsError = String(err); } @@ -831,7 +995,11 @@ export async function deleteSessionsAndRefresh( const refreshedDuringDelete = await withSessionsLoading(state, async () => { for (const key of keys) { try { - await client.request("sessions.delete", { key, deleteTranscript: true }); + await client.request("sessions.delete", { + key, + ...(isGlobalSessionKey(key) ? { agentId: resolveSelectedGlobalAgentId(state) } : {}), + deleteTranscript: true, + }); deleted.push(key); } catch (err) { deleteErrors.push(String(err)); @@ -839,7 +1007,11 @@ export async function deleteSessionsAndRefresh( } }); if (deleted.length > 0 && !refreshedDuringDelete) { - await loadSessions(state); + const selectedGlobalDeleted = deleted.some((key) => isGlobalSessionKey(key)); + await loadSessions( + state, + selectedGlobalDeleted ? { agentId: resolveSelectedGlobalAgentId(state) } : undefined, + ); } if (deleteErrors.length > 0) { state.sessionsError = deleteErrors.join("; "); diff --git a/ui/src/ui/session-goal.test.ts b/ui/src/ui/session-goal.test.ts new file mode 100644 index 000000000000..3150e269426e --- /dev/null +++ b/ui/src/ui/session-goal.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { formatGoalDetail, formatGoalSummary, formatGoalTokenCount } from "./session-goal.ts"; +import type { SessionGoal } from "./types.ts"; + +function buildGoal(overrides: Partial = {}): SessionGoal { + return { + schemaVersion: 1, + id: "goal-1", + objective: "Ship the web goal indicator", + status: "active", + createdAt: 1, + updatedAt: 2, + tokenStart: 100, + tokensUsed: 12_400, + tokenBudget: 50_000, + continuationTurns: 0, + ...overrides, + }; +} + +describe("session goal formatting", () => { + it("formats compact token counts for goal usage", () => { + expect(formatGoalTokenCount(999)).toBe("999"); + expect(formatGoalTokenCount(1_240)).toBe("1.2k"); + expect(formatGoalTokenCount(12_400)).toBe("12k"); + expect(formatGoalTokenCount(999_999)).toBe("1m"); + expect(formatGoalTokenCount(1_240_000)).toBe("1.2m"); + }); + + it("summarizes goal status and objective details", () => { + const goal = buildGoal({ lastStatusNote: "Waiting for CI" }); + + expect(formatGoalSummary(goal)).toBe("Pursuing goal (12k/50k)"); + expect(formatGoalDetail(goal)).toBe( + "Pursuing goal (12k/50k): Ship the web goal indicator - Waiting for CI", + ); + }); + + it("uses terminal labels without a budget", () => { + expect(formatGoalSummary(buildGoal({ status: "complete", tokenBudget: undefined }))).toBe( + "Goal achieved (12k used)", + ); + }); +}); diff --git a/ui/src/ui/session-goal.ts b/ui/src/ui/session-goal.ts new file mode 100644 index 000000000000..b20dfbcd3df8 --- /dev/null +++ b/ui/src/ui/session-goal.ts @@ -0,0 +1,60 @@ +import type { SessionGoal } from "./types.ts"; + +export function formatGoalTokenCount(value: number): string { + if (!Number.isFinite(value) || value <= 0) { + return "0"; + } + if (value < 1000) { + return String(Math.round(value)); + } + if (value < 1_000_000) { + const rounded = value >= 10_000 ? Math.round(value / 1000) : Math.round(value / 100) / 10; + if (rounded >= 1000) { + return "1m"; + } + return `${rounded}k`; + } + const rounded = + value >= 10_000_000 ? Math.round(value / 1_000_000) : Math.round(value / 100_000) / 10; + return `${rounded}m`; +} + +export function formatGoalUsage(goal: SessionGoal): string | null { + if (typeof goal.tokenBudget === "number" && Number.isFinite(goal.tokenBudget)) { + return `${formatGoalTokenCount(goal.tokensUsed)}/${formatGoalTokenCount(goal.tokenBudget)}`; + } + if (goal.tokensUsed > 0) { + return `${formatGoalTokenCount(goal.tokensUsed)} used`; + } + return null; +} + +export function formatGoalStatusLabel(status: SessionGoal["status"]): string { + switch (status) { + case "active": + return "Pursuing goal"; + case "paused": + return "Goal paused"; + case "blocked": + return "Goal blocked"; + case "usage_limited": + return "Goal hit usage limits"; + case "budget_limited": + return "Goal unmet"; + case "complete": + return "Goal achieved"; + } + const unreachable: never = status; + return unreachable; +} + +export function formatGoalSummary(goal: SessionGoal): string { + const usage = formatGoalUsage(goal); + const status = formatGoalStatusLabel(goal.status); + return usage ? `${status} (${usage})` : status; +} + +export function formatGoalDetail(goal: SessionGoal): string { + const note = goal.lastStatusNote ? ` - ${goal.lastStatusNote}` : ""; + return `${formatGoalSummary(goal)}: ${goal.objective}${note}`; +} diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts index 6b15eea6e4b6..8857627d810c 100644 --- a/ui/src/ui/types.ts +++ b/ui/src/ui/types.ts @@ -1,4 +1,5 @@ export type UpdateAvailable = import("../../../src/infra/update-startup.js").UpdateAvailable; +import type { SessionGoal } from "../../../src/config/sessions/types.js"; import type { CronJobBase } from "../../../src/cron/types-shared.js"; import type { ConfigUiHints } from "../../../src/shared/config-ui-hints-types.js"; import type { @@ -8,6 +9,7 @@ import type { SessionsPatchResultBase, } from "../../../src/shared/session-types.js"; export type { ConfigUiHint, ConfigUiHints } from "../../../src/shared/config-ui-hints-types.js"; +export type { SessionGoal } from "../../../src/config/sessions/types.js"; export type ChannelsStatusSnapshot = { ts: number; @@ -460,6 +462,7 @@ export type GatewaySessionRow = { contextTokens?: number; compactionCheckpointCount?: number; latestCompactionCheckpoint?: SessionCompactionCheckpointPreview; + goal?: SessionGoal; }; export type SessionsListResult = SessionsListResultBase; diff --git a/ui/src/ui/ui-types.ts b/ui/src/ui/ui-types.ts index 74a43a4ee542..7c74d57a3823 100644 --- a/ui/src/ui/ui-types.ts +++ b/ui/src/ui/ui-types.ts @@ -22,6 +22,7 @@ export type ChatQueueItem = { sendRunId?: string; sendState?: "sending" | "waiting-reconnect" | "failed"; sessionKey?: string; + agentId?: string; }; export const CRON_CHANNEL_LAST = "last"; diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index f7c25a4f0760..d3a6a4aafc1f 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -543,6 +543,38 @@ describe("chat compaction divider", () => { }); }); +describe("chat goal status", () => { + it("renders the active session goal above the composer", () => { + const container = renderChatView({ + sessions: createSessionsResultFromRows([ + { + key: "main", + kind: "direct", + updatedAt: 2, + goal: { + schemaVersion: 1, + id: "goal-1", + objective: "Land the web goal UI", + status: "active", + createdAt: 1, + updatedAt: 2, + tokenStart: 100, + tokensUsed: 12_400, + tokenBudget: 50_000, + continuationTurns: 0, + }, + }, + ]), + }); + + const goal = container.querySelector(".agent-chat__goal"); + expect(goal?.textContent?.replace(/\s+/g, " ").trim()).toBe( + "Pursuing goal (12k/50k) Land the web goal UI", + ); + expect(goal?.getAttribute("aria-label")).toBe("Pursuing goal (12k/50k): Land the web goal UI"); + }); +}); + afterEach(() => { vi.useRealTimers(); loadSessionsMock.mockClear(); diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index e9ab007b3424..6faeb1911cce 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -51,9 +51,10 @@ import { import { getExpandedToolCards, syncToolCardExpansionState } from "../chat/tool-expansion-state.ts"; import type { EmbedSandboxMode } from "../embed-sandbox.ts"; import { icons } from "../icons.ts"; +import { formatGoalDetail, formatGoalSummary } from "../session-goal.ts"; import type { SidebarContent } from "../sidebar-content.ts"; import { detectTextDirection } from "../text-direction.ts"; -import type { SessionsListResult } from "../types.ts"; +import type { SessionGoal, SessionsListResult } from "../types.ts"; import type { ChatAttachment, ChatQueueItem } from "../ui-types.ts"; import { resolveLocalUserName } from "../user-identity.ts"; import { renderMarkdownSidebar } from "./markdown-sidebar.ts"; @@ -635,6 +636,23 @@ function renderAttachmentPreview(props: ChatProps): TemplateResult | typeof noth `; } +function renderChatGoal(goal: SessionGoal | undefined): TemplateResult | typeof nothing { + if (!goal) { + return nothing; + } + return html` +
+ ${formatGoalSummary(goal)} + ${goal.objective} +
+ `; +} + function resetSlashMenuState(): void { vs.slashMenuMode = "command"; vs.slashMenuCommand = null; @@ -1506,6 +1524,7 @@ export function renderChat(props: ChatProps) { compactDisabled: !props.connected || isBusy || showAbortableUi, onCompact: props.onCompact, })} + ${renderChatGoal(activeSession?.goal)} ${props.showNewMessages ? html`