feat: add core session goals (#87469)

* feat: add core session goals

* feat: polish session goals in tui

* fix: resolve goal tool session stores

* fix: keep get goal read-only

* fix: migrate legacy goal session slots

* fix: persist goal token accounting

* fix: validate goal session rows

* refactor: remove unshipped goal legacy handling

* fix: handle goal commands in local tui

* fix: satisfy goal tool display checks

* fix: reset goal budget on overdue resume

* feat: surface session goals across control surfaces

* test: update gateway protocol test import

* test: align goal fixture types with protocol

* fix: scope selected global transcript usage fallback

* fix: scope selected global web subscriptions

* fix: preserve selected global agent during chat dispatch

* fix: scope chat inject to selected global agents
This commit is contained in:
Peter Steinberger
2026-05-29 22:36:29 +02:00
committed by GitHub
parent 057be10e5b
commit a509c48f0e
138 changed files with 10462 additions and 483 deletions

View File

@@ -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",

View File

@@ -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)

View File

@@ -1333,6 +1333,7 @@
"group": "Agent coordination",
"pages": [
"tools/agent-send",
"tools/goal",
"tools/steer",
"tools/subagents",
"tools/acp-agents",

217
docs/tools/goal.md Normal file
View File

@@ -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 <objective>` creates a new goal for the current session.
- `/goal set <objective>` and `/goal create <objective>` 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 <objective>`.
`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)

View File

@@ -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) |
<Note>
Tool Search is an experimental OpenClaw agent surface. Codex harness runs use

View File

@@ -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 <objective> | /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 <thread-id>` commands. See [Diagnostics Export](/gateway/diagnostics).
- `/crestodian <request>` runs the Crestodian setup and repair helper from an owner DM.
- `/tasks` lists active/recent background tasks for the current session.

View File

@@ -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 <on|off>`
- `/reasoning <on|off|stream>`
- `/usage <off|tokens|full>`
- `/goal [status] | /goal start <objective> | /goal pause|resume|complete|block|clear`
- `/elevated <on|off|ask|full>` (alias: `/elev`)
- `/activation <mention|always>`
- `/deliver <on|off>`

View File

@@ -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,

View File

@@ -383,6 +383,7 @@ async function mirrorTelegramAssistantReplyToTranscript(params: {
emitSessionTranscriptUpdate({
sessionFile,
sessionKey: params.sessionKey,
agentId: params.route.agentId,
message: appendedMessage,
messageId,
});

View File

@@ -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({

View File

@@ -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 }),
};

View File

@@ -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 },

View File

@@ -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<string, unknown>;
@@ -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"));

View File

@@ -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`. */

View File

@@ -299,6 +299,7 @@ export function resolveSessionAgentIds(params: {
export function resolveSessionAgentId(params: {
sessionKey?: string;
config?: OpenClawConfig;
agentId?: string;
}): string {
return resolveSessionAgentIds(params).sessionAgentId;
}

View File

@@ -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;
}

View File

@@ -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,
});

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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;

View File

@@ -20,6 +20,7 @@ function resolvePostCompactionIndexSyncMode(config?: OpenClawConfig): "off" | "a
async function runPostCompactionSessionMemorySync(params: {
config?: OpenClawConfig;
sessionKey?: string;
agentId?: string;
sessionFile: string;
}): Promise<void> {
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<void> {
@@ -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<void> {
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),
});

View File

@@ -335,6 +335,7 @@ export function buildContextEngineMaintenanceRuntimeContext(params: {
sessionFile: params.sessionFile,
sessionId: params.sessionId,
sessionKey: params.sessionKey,
agentId: params.agentId,
config: params.config,
request,
});

View File

@@ -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) {

View File

@@ -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",

View File

@@ -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 = {

View File

@@ -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;

View File

@@ -376,6 +376,7 @@ export async function rewriteTranscriptEntriesInSessionFile(params: {
sessionFile: string;
sessionId?: string;
sessionKey?: string;
agentId?: string;
request: TranscriptRewriteRequest;
config?: SessionWriteLockAcquireTimeoutConfig;
}): Promise<TranscriptRewriteResult> {
@@ -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` +

View File

@@ -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,

View File

@@ -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;

View File

@@ -43,6 +43,7 @@ describe("guardSessionManager transcript updates", () => {
expect(updates).toStrictEqual([
{
agentId: "main",
message: {
content: [{ text: "hello from subagent", type: "text" }],
role: "assistant",

View File

@@ -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 } : {}),

View File

@@ -41,6 +41,9 @@ describe("tool-catalog", () => {
"subagents",
"session_status",
"cron",
"get_goal",
"create_goal",
"update_goal",
"update_plan",
"image",
"image_generate",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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", () => {

View File

@@ -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);

View File

@@ -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" },

View File

@@ -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 = <T = Record<string, unknown>>(opts: CallGatewayOptions) => Promise<T>;
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<SessionsListResult>;
loadCombinedSessionStoreForGateway: (cfg: OpenClawConfig) => {
loadCombinedSessionStoreForGateway: (
cfg: OpenClawConfig,
opts?: { agentId?: string },
) => {
storePath: string;
store: unknown;
};
@@ -49,7 +57,10 @@ interface EmbeddedGatewayRuntime {
cfg: OpenClawConfig;
p: SessionsResolveParams;
}) => Promise<SessionsResolveResult>;
loadSessionEntry: (sessionKey: string) => {
loadSessionEntry: (
sessionKey: string,
opts?: { agentId?: string },
) => {
cfg: OpenClawConfig;
storePath: string | undefined;
entry: Record<string, unknown> | undefined;
@@ -79,12 +90,15 @@ async function getRuntime(): Promise<EmbeddedGatewayRuntime> {
async function handleSessionsList(params: Record<string, unknown>) {
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<string, unknown>): 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;

View File

@@ -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();
});
});

View File

@@ -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<string, unknown>;
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<string, unknown>;
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 });
},
};
}

View File

@@ -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",

View File

@@ -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 <objective>");
}
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 <objective> | /goal pause|resume|complete|block|clear",
);
}
} catch (error) {
return goalErrorReply(error);
}
};

View File

@@ -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,

View File

@@ -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,

View File

@@ -277,6 +277,7 @@ export async function getReplyFromConfig(
agentId: resolveSessionAgentId({
sessionKey: resolvedAgentSessionKey,
config: cfg,
agentId: finalized.AgentId,
}),
};
},

View File

@@ -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 ||

View File

@@ -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";

View File

@@ -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;

View File

@@ -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.

View File

@@ -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";

View File

@@ -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();
});
});

View File

@@ -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<SessionGoalStatus, "active" | "paused" | "blocked" | "complete">;
note?: string;
};
export const MODEL_UPDATABLE_SESSION_GOAL_STATUSES = ["complete", "blocked"] as const;
const TERMINAL_GOAL_STATUSES = new Set<SessionGoalStatus>(["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<SessionEntry, "totalTokens" | "totalTokensFresh">,
): number | undefined {
return normalizeTokenCount(resolveFreshSessionTotalTokens(entry));
}
function resolveEntryGoalStartTokens(
entry: Pick<SessionEntry, "totalTokens" | "totalTokensFresh">,
): 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<SessionEntry, "goal" | "totalTokens" | "totalTokensFresh">,
now?: number,
options?: { adoptFreshBaseline?: boolean },
): SessionGoal | undefined {
return accountGoalUsage(entry, nowMs(now), options);
}
function accountGoalUsage(
entry: Pick<SessionEntry, "goal" | "totalTokens" | "totalTokensFresh">,
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 <objective>.";
}
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<SessionGoalSnapshot> {
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<SessionGoal> {
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<SessionGoal> {
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<boolean> {
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);
}

View File

@@ -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;

View File

@@ -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. */

View File

@@ -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";

View File

@@ -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<string, ChatAbortControllerEntry>;
chatRunBuffers: Map<string, string>;
@@ -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<string, number>;
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",

View File

@@ -46,7 +46,7 @@ export function createLocalGatewayRequestContext(
): GatewayRequestContext {
const logGateway = createSubsystemLogger("gateway/local");
const sessionEvents = new Set<string>();
const chatRuns = new Map<string, { sessionKey: string; clientRunId: string }>();
const chatRuns = new Map<string, { sessionKey: string; agentId?: string; clientRunId: string }>();
const chatRunBuffers: GatewayRequestContext["chatRunBuffers"] = new Map();
const chatDeltaSentAt: GatewayRequestContext["chatDeltaSentAt"] = new Map();
const chatDeltaLastBroadcastLen: GatewayRequestContext["chatDeltaLastBroadcastLen"] = new Map();

View File

@@ -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<string, unknown> = {
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);
});
});

View File

@@ -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<CleanupManagedOutgoingImageRecordsResult> {
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<string, SessionManagedOutgoingAttachmentIndex | null>,
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",

View File

@@ -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 };
};

View File

@@ -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({

View File

@@ -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 },

View File

@@ -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<string, number>;
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<string, number>;
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<string, number>;
nodeSendToSession: (sessionKey: string, event: string, payload: unknown) => void;
getPendingReplyCount?: () => number;

View File

@@ -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<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = {
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<void>((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(

View File

@@ -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<SessionEntry>;
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({

View File

@@ -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,
});

View File

@@ -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<string, unknown>,
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<typeof import("../session-utils.js")>("../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();

View File

@@ -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<string, unknown> & {
bufferedAgentEvents: Map<string, unknown>;
chatAbortedRuns: Map<string, number>;
clearChatRunState: (runId: string) => void;
removeChatRun: (...args: unknown[]) => { sessionKey: string; clientRunId: string } | undefined;
removeChatRun: (
...args: unknown[]
) => { sessionKey: string; agentId?: string; clientRunId: string } | undefined;
agentRunSeq: Map<string, number>;
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<string, number>(),
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?: {

View File

@@ -54,6 +54,7 @@ const mockState = vi.hoisted(() => ({
};
}>,
dispatchError: null as Error | null,
dispatchWait: null as Promise<void> | 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<string, unknown>,
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<typeof import("../session-utils.js")>("../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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<typeof vi.fn>;
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<typeof vi.fn>;
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}`;

View File

@@ -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",
}),
);
});
});

View File

@@ -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 };

File diff suppressed because it is too large Load Diff

View File

@@ -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<typeof import("../session-utils.js")>("../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(),

View File

@@ -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<string>(),
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<string, unknown> }
| undefined;
expect(chatSendCall?.params).toMatchObject({
sessionKey: "global",
agentId: "work",
message: "follow-up",
});
expect(respondMock.mock.calls.at(0)?.[0]).toBe(true);
});
}
});

View File

@@ -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<Pick<GatewayRequestContext, "chatAbortControllers">>,
): Set<string> {
const keys = new Set<string>();
): 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<Pick<GatewayRequestContext, "chatAbortControllers">>;
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<typeof errorShape> };
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<typeof errorShape> }> {
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<ReturnType<typeof compactEmbeddedAgentSession>>;
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,
});

View File

@@ -95,12 +95,15 @@ export type GatewayRequestContext = {
agentDeltaSentAt: Map<string, number>;
bufferedAgentEvents: Map<string, BufferedAgentEvent>;
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;

View File

@@ -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<SessionEventSubscriberRegistry, "getAll">;
type SessionMessageSubscribers = Pick<SessionMessageSubscriberRegistry, "get">;
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<string>();
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 } : {}),

View File

@@ -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(

View File

@@ -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();

View File

@@ -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");

View File

@@ -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({

View File

@@ -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");

View File

@@ -145,6 +145,7 @@ export function derivePersistedSessionLifecyclePatch(params: {
export async function persistGatewaySessionLifecycleEvent(params: {
sessionKey: string;
agentId?: string;
event: LifecycleEventLike;
}): Promise<void> {
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;
}

View File

@@ -68,6 +68,7 @@ async function withOperatorSessionSubscriber<T>(
function waitForSessionMessageEvent(
ws: Awaited<ReturnType<Awaited<ReturnType<typeof createGatewaySuiteHarness>>["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<string, unknown>;
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");

View File

@@ -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<typeof errorShape> }
> {
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 };
}

View File

@@ -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",

View File

@@ -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<string, SessionEntry>;
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<string, string[]>;
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,

View File

@@ -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;

View File

@@ -18,12 +18,14 @@ async function runPatch(params: {
store?: Record<string, SessionEntry>;
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({

View File

@@ -133,13 +133,16 @@ export async function applySessionsPatchToStore(params: {
cfg: OpenClawConfig;
store: Record<string, SessionEntry>;
storeKey: string;
agentId?: string;
patch: SessionsPatchParams;
loadGatewayModelCatalog?: () => Promise<ModelCatalogEntry[]>;
}): 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 })

View File

@@ -106,6 +106,7 @@ export type AgentEventPayload = {
ts: number;
data: Record<string, unknown>;
sessionKey?: string;
agentId?: string;
};
export type AgentRunContext = {

View File

@@ -28,6 +28,7 @@ const SESSION_ENTRY_RESERVED_SLOT_KEY_LIST = [
"pluginOwnerId",
"systemSent",
"abortedLastRun",
"goal",
"sessionStartedAt",
"lastInteractionAt",
"startedAt",

View File

@@ -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,

View File

@@ -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) }

View File

@@ -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,
});

View File

@@ -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<string, unknown>;
canonicalKey: string;
storePath?: string;
entry?: Record<string, unknown>;
};
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<string, unknown>) => 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<string, unknown>;
}>();
const second = deferred<{
payloads: Array<{ text: string }>;
meta: Record<string, unknown>;
}>();
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<string, unknown>;
}>();
const stop = deferred<{
payloads: Array<{ text: string }>;
meta: Record<string, unknown>;
}>();
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<string, unknown>;
}>();
const workRun = deferred<{
payloads: Array<{ text: string }>;
meta: Record<string, unknown>;
}>();
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";

View File

@@ -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<TuiBackend["listSessions"]>[0]): Promise<TuiSessionList> {
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<TuiBackend["patchSession"]>[0],
): Promise<SessionsPatchResult> {
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<NonNullable<TuiBackend["runGoalCommand"]>>[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 <objective>" };
}
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 <objective> | /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,

View File

@@ -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",

View File

@@ -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<SessionsPatchResult>("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 } : {}),
});
}

View File

@@ -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<unknown>;
loadHistory: (opts: { sessionKey: string; agentId?: string; limit?: number }) => Promise<unknown>;
listSessions: (opts?: SessionsListParams) => Promise<TuiSessionList>;
listAgents: () => Promise<TuiAgentsList>;
patchSession: (opts: SessionsPatchParams) => Promise<SessionsPatchResult>;
resetSession: (key: string, reason?: "new" | "reset") => Promise<unknown>;
resetSession: (
key: string,
reason?: "new" | "reset",
opts?: { agentId?: string },
) => Promise<unknown>;
getGatewayStatus: () => Promise<unknown>;
listModels: () => Promise<TuiModelChoice[]>;
listCommands?: (opts?: CommandsListParams) => Promise<CommandEntry[]>;
runGoalCommand?: (opts: TuiGoalCommandOptions) => Promise<{ text: string }>;
};

Some files were not shown because too many files have changed in this diff Show More