From d346f2d9ce6d2aefa18b0f8fc4fa90507a456b65 Mon Sep 17 00:00:00 2001 From: Mariano <132747814+mbelinky@users.noreply.github.com> Date: Mon, 9 Mar 2026 22:17:19 +0100 Subject: [PATCH] acp: restore session context and controls (#41425) Merged via squash. Prepared head SHA: fcabdf7c31e33bbbd3ef82bdee92755eb0f62c82 Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Reviewed-by: @mbelinky --- CHANGELOG.md | 1 + docs.acp.md | 46 +- docs/cli/acp.md | 46 +- src/acp/translator.session-rate-limit.test.ts | 467 ++++++++++++++++- src/acp/translator.test-helpers.ts | 12 +- src/acp/translator.ts | 478 +++++++++++++++++- 6 files changed, 1003 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bad484b74df..5e80d751c9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ Docs: https://docs.openclaw.ai - Auth/cooldowns: reset expired auth-profile cooldown error counters before computing the next backoff so stale on-disk counters do not re-escalate into long cooldown loops after expiry. (#41028) thanks @zerone0x. - Gateway/node pending drain followup: keep `hasMore` true when the deferred baseline status item still needs delivery, and avoid allocating empty pending-work state for drain-only nodes with no queued work. (#41429) Thanks @mbelinky. - ACP/bridge mode: reject unsupported per-session MCP server setup and propagate rejected session-mode changes so IDE clients see explicit bridge limitations instead of silent success. (#41424) Thanks @mbelinky. +- ACP/session UX: replay stored user and assistant text on `loadSession`, expose Gateway-backed session controls and metadata, and emit approximate session usage updates so IDE clients restore context more faithfully. (#41425) Thanks @mbelinky. ## 2026.3.8 diff --git a/docs.acp.md b/docs.acp.md index 48613138141..99fe15fbbd6 100644 --- a/docs.acp.md +++ b/docs.acp.md @@ -25,31 +25,41 @@ session with predictable session mapping and basic streaming updates. ## Compatibility Matrix -| ACP area | Status | Notes | -| --------------------------------------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------- | -| `initialize`, `newSession`, `prompt`, `cancel` | Implemented | Core bridge flow over stdio to Gateway chat/send + abort. | -| `listSessions`, slash commands | Implemented | Session list works against Gateway session state; commands are advertised via `available_commands_update`. | -| `loadSession` | Partial | Rebinds the ACP session to a Gateway session key. Stored history is not replayed yet. | -| Prompt content (`text`, embedded `resource`, images) | Partial | Text/resources are flattened into chat input; images become Gateway attachments. | -| Session modes | Partial | `session/set_mode` is supported, but this bridge does not yet expose broader ACP-native mode or config surfaces. | -| Tool streaming | Partial | Tool start and result updates are forwarded, but without ACP-native terminal or richer editor metadata. | -| Per-session MCP servers (`mcpServers`) | Unsupported | Bridge mode rejects per-session MCP server requests. Configure MCP on the OpenClaw gateway or agent instead. | -| Client filesystem methods (`fs/read_text_file`, `fs/write_text_file`) | Unsupported | The bridge does not call ACP client filesystem methods. | -| Client terminal methods (`terminal/*`) | Unsupported | The bridge does not create ACP client terminals or stream terminal ids through tool calls. | -| Session plans / thought streaming | Unsupported | The bridge currently emits output text and tool status, not ACP plan or thought updates. | +| ACP area | Status | Notes | +| --------------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `initialize`, `newSession`, `prompt`, `cancel` | Implemented | Core bridge flow over stdio to Gateway chat/send + abort. | +| `listSessions`, slash commands | Implemented | Session list works against Gateway session state; commands are advertised via `available_commands_update`. | +| `loadSession` | Partial | Rebinds the ACP session to a Gateway session key and replays stored user/assistant text history. Tool/system history is not reconstructed yet. | +| Prompt content (`text`, embedded `resource`, images) | Partial | Text/resources are flattened into chat input; images become Gateway attachments. | +| Session modes | Partial | `session/set_mode` is supported and the bridge exposes initial Gateway-backed session controls for thought level, tool verbosity, reasoning, usage detail, and elevated actions. Broader ACP-native mode/config surfaces are still out of scope. | +| Session info and usage updates | Partial | The bridge emits `session_info_update` and best-effort `usage_update` notifications from cached Gateway session snapshots. Usage is approximate and only sent when Gateway token totals are marked fresh. | +| Tool streaming | Partial | Tool start and result updates are forwarded, but without richer editor metadata such as file locations or structured diff-native output. | +| Per-session MCP servers (`mcpServers`) | Unsupported | Bridge mode rejects per-session MCP server requests. Configure MCP on the OpenClaw gateway or agent instead. | +| Client filesystem methods (`fs/read_text_file`, `fs/write_text_file`) | Unsupported | The bridge does not call ACP client filesystem methods. | +| Client terminal methods (`terminal/*`) | Unsupported | The bridge does not create ACP client terminals or stream terminal ids through tool calls. | +| Session plans / thought streaming | Unsupported | The bridge currently emits output text and tool status, not ACP plan or thought updates. | ## Known Limitations -- `loadSession` rebinds to an existing Gateway session, but it does not replay - prior user or assistant history yet. +- `loadSession` replays stored user and assistant text history, but it does not + reconstruct historic tool calls, system notices, or richer ACP-native event + types. - If multiple ACP clients share the same Gateway session key, event and cancel routing are best-effort rather than strictly isolated per client. Prefer the default isolated `acp:` sessions when you need clean editor-local turns. - Gateway stop states are translated into ACP stop reasons, but that mapping is less expressive than a fully ACP-native runtime. -- Tool follow-along data is intentionally narrow in bridge mode. The bridge - does not yet emit ACP terminals, file locations, or structured diffs. +- Initial session controls currently surface a focused subset of Gateway knobs: + thought level, tool verbosity, reasoning, usage detail, and elevated + actions. Model selection and exec-host controls are not yet exposed as ACP + config options. +- `session_info_update` and `usage_update` are derived from Gateway session + snapshots, not live ACP-native runtime accounting. Usage is approximate, + carries no cost data, and is only emitted when the Gateway marks total token + data as fresh. +- Tool follow-along data is still intentionally narrow in bridge mode. The + bridge does not yet emit ACP terminals, file locations, or structured diffs. ## How can I use this @@ -215,9 +225,11 @@ updates. Terminal Gateway states map to ACP `done` with stop reasons: ## Compatibility -- ACP bridge uses `@agentclientprotocol/sdk` (currently 0.13.x). +- ACP bridge uses `@agentclientprotocol/sdk` (currently 0.15.x). - Works with ACP clients that implement `initialize`, `newSession`, `loadSession`, `prompt`, `cancel`, and `listSessions`. +- Bridge mode rejects per-session `mcpServers` instead of silently ignoring + them. Configure MCP at the Gateway or agent layer. ## Testing diff --git a/docs/cli/acp.md b/docs/cli/acp.md index fbd46e428d3..7693d707862 100644 --- a/docs/cli/acp.md +++ b/docs/cli/acp.md @@ -19,31 +19,41 @@ updates. ## Compatibility Matrix -| ACP area | Status | Notes | -| --------------------------------------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------- | -| `initialize`, `newSession`, `prompt`, `cancel` | Implemented | Core bridge flow over stdio to Gateway chat/send + abort. | -| `listSessions`, slash commands | Implemented | Session list works against Gateway session state; commands are advertised via `available_commands_update`. | -| `loadSession` | Partial | Rebinds the ACP session to a Gateway session key. Stored history is not replayed yet. | -| Prompt content (`text`, embedded `resource`, images) | Partial | Text/resources are flattened into chat input; images become Gateway attachments. | -| Session modes | Partial | `session/set_mode` is supported, but this bridge does not yet expose broader ACP-native mode or config surfaces. | -| Tool streaming | Partial | Tool start and result updates are forwarded, but without ACP-native terminal or richer editor metadata. | -| Per-session MCP servers (`mcpServers`) | Unsupported | Bridge mode rejects per-session MCP server requests. Configure MCP on the OpenClaw gateway or agent instead. | -| Client filesystem methods (`fs/read_text_file`, `fs/write_text_file`) | Unsupported | The bridge does not call ACP client filesystem methods. | -| Client terminal methods (`terminal/*`) | Unsupported | The bridge does not create ACP client terminals or stream terminal ids through tool calls. | -| Session plans / thought streaming | Unsupported | The bridge currently emits output text and tool status, not ACP plan or thought updates. | +| ACP area | Status | Notes | +| --------------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `initialize`, `newSession`, `prompt`, `cancel` | Implemented | Core bridge flow over stdio to Gateway chat/send + abort. | +| `listSessions`, slash commands | Implemented | Session list works against Gateway session state; commands are advertised via `available_commands_update`. | +| `loadSession` | Partial | Rebinds the ACP session to a Gateway session key and replays stored user/assistant text history. Tool/system history is not reconstructed yet. | +| Prompt content (`text`, embedded `resource`, images) | Partial | Text/resources are flattened into chat input; images become Gateway attachments. | +| Session modes | Partial | `session/set_mode` is supported and the bridge exposes initial Gateway-backed session controls for thought level, tool verbosity, reasoning, usage detail, and elevated actions. Broader ACP-native mode/config surfaces are still out of scope. | +| Session info and usage updates | Partial | The bridge emits `session_info_update` and best-effort `usage_update` notifications from cached Gateway session snapshots. Usage is approximate and only sent when Gateway token totals are marked fresh. | +| Tool streaming | Partial | Tool start and result updates are forwarded, but without richer editor metadata such as file locations or structured diff-native output. | +| Per-session MCP servers (`mcpServers`) | Unsupported | Bridge mode rejects per-session MCP server requests. Configure MCP on the OpenClaw gateway or agent instead. | +| Client filesystem methods (`fs/read_text_file`, `fs/write_text_file`) | Unsupported | The bridge does not call ACP client filesystem methods. | +| Client terminal methods (`terminal/*`) | Unsupported | The bridge does not create ACP client terminals or stream terminal ids through tool calls. | +| Session plans / thought streaming | Unsupported | The bridge currently emits output text and tool status, not ACP plan or thought updates. | ## Known Limitations -- `loadSession` rebinds to an existing Gateway session, but it does not replay - prior user or assistant history yet. +- `loadSession` replays stored user and assistant text history, but it does not + reconstruct historic tool calls, system notices, or richer ACP-native event + types. - If multiple ACP clients share the same Gateway session key, event and cancel routing are best-effort rather than strictly isolated per client. Prefer the default isolated `acp:` sessions when you need clean editor-local turns. - Gateway stop states are translated into ACP stop reasons, but that mapping is less expressive than a fully ACP-native runtime. -- Tool follow-along data is intentionally narrow in bridge mode. The bridge - does not yet emit ACP terminals, file locations, or structured diffs. +- Initial session controls currently surface a focused subset of Gateway knobs: + thought level, tool verbosity, reasoning, usage detail, and elevated + actions. Model selection and exec-host controls are not yet exposed as ACP + config options. +- `session_info_update` and `usage_update` are derived from Gateway session + snapshots, not live ACP-native runtime accounting. Usage is approximate, + carries no cost data, and is only emitted when the Gateway marks total token + data as fresh. +- Tool follow-along data is still intentionally narrow in bridge mode. The + bridge does not yet emit ACP terminals, file locations, or structured diffs. ## Usage @@ -128,6 +138,10 @@ Each ACP session maps to a single Gateway session key. One agent can have many sessions; ACP defaults to an isolated `acp:` session unless you override the key or label. +Per-session `mcpServers` are not supported in bridge mode. If an ACP client +sends them during `newSession` or `loadSession`, the bridge returns a clear +error instead of silently ignoring them. + ## Use from `acpx` (Codex, Claude, other ACP clients) If you want a coding agent such as Codex or Claude Code to talk to your diff --git a/src/acp/translator.session-rate-limit.test.ts b/src/acp/translator.session-rate-limit.test.ts index 51d6cc1f8e2..07d8bbc3db7 100644 --- a/src/acp/translator.session-rate-limit.test.ts +++ b/src/acp/translator.session-rate-limit.test.ts @@ -2,10 +2,12 @@ import type { LoadSessionRequest, NewSessionRequest, PromptRequest, + SetSessionConfigOptionRequest, SetSessionModeRequest, } from "@agentclientprotocol/sdk"; import { describe, expect, it, vi } from "vitest"; import type { GatewayClient } from "../gateway/client.js"; +import type { EventFrame } from "../gateway/protocol/index.js"; import { createInMemorySessionStore } from "./session.js"; import { AcpGatewayAgent } from "./translator.js"; import { createAcpConnection, createAcpGateway } from "./translator.test-helpers.js"; @@ -47,6 +49,29 @@ function createSetSessionModeRequest(sessionId: string, modeId: string): SetSess } as unknown as SetSessionModeRequest; } +function createSetSessionConfigOptionRequest( + sessionId: string, + configId: string, + value: string, +): SetSessionConfigOptionRequest { + return { + sessionId, + configId, + value, + _meta: {}, + } as unknown as SetSessionConfigOptionRequest; +} + +function createChatFinalEvent(sessionKey: string): EventFrame { + return { + event: "chat", + payload: { + sessionKey, + state: "final", + }, + } as unknown as EventFrame; +} + async function expectOversizedPromptRejected(params: { sessionId: string; text: string }) { const request = vi.fn(async () => ({ ok: true })) as GatewayClient["request"]; const sessionStore = createInMemorySessionStore(); @@ -110,7 +135,7 @@ describe("acp unsupported bridge session setup", () => { it("rejects per-session MCP servers on newSession", async () => { const sessionStore = createInMemorySessionStore(); const connection = createAcpConnection(); - const sessionUpdate = vi.spyOn(connection, "sessionUpdate"); + const sessionUpdate = connection.__sessionUpdateMock; const agent = new AcpGatewayAgent(connection, createAcpGateway(), { sessionStore, }); @@ -130,7 +155,7 @@ describe("acp unsupported bridge session setup", () => { it("rejects per-session MCP servers on loadSession", async () => { const sessionStore = createInMemorySessionStore(); const connection = createAcpConnection(); - const sessionUpdate = vi.spyOn(connection, "sessionUpdate"); + const sessionUpdate = connection.__sessionUpdateMock; const agent = new AcpGatewayAgent(connection, createAcpGateway(), { sessionStore, }); @@ -148,6 +173,172 @@ describe("acp unsupported bridge session setup", () => { }); }); +describe("acp session UX bridge behavior", () => { + it("returns initial modes and thought-level config options for new sessions", async () => { + const sessionStore = createInMemorySessionStore(); + const agent = new AcpGatewayAgent(createAcpConnection(), createAcpGateway(), { + sessionStore, + }); + + const result = await agent.newSession(createNewSessionRequest()); + + expect(result.modes?.currentModeId).toBe("adaptive"); + expect(result.modes?.availableModes.map((mode) => mode.id)).toContain("adaptive"); + expect(result.configOptions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "thought_level", + currentValue: "adaptive", + category: "thought_level", + }), + expect.objectContaining({ + id: "verbose_level", + currentValue: "off", + }), + expect.objectContaining({ + id: "reasoning_level", + currentValue: "off", + }), + expect.objectContaining({ + id: "response_usage", + currentValue: "off", + }), + expect.objectContaining({ + id: "elevated_level", + currentValue: "off", + }), + ]), + ); + + sessionStore.clearAllSessionsForTest(); + }); + + it("replays user and assistant text history on loadSession and returns initial controls", async () => { + const sessionStore = createInMemorySessionStore(); + const connection = createAcpConnection(); + const sessionUpdate = connection.__sessionUpdateMock; + const request = vi.fn(async (method: string) => { + if (method === "sessions.list") { + return { + ts: Date.now(), + path: "/tmp/sessions.json", + count: 1, + defaults: { + modelProvider: null, + model: null, + contextTokens: null, + }, + sessions: [ + { + key: "agent:main:work", + label: "main-work", + displayName: "Main work", + derivedTitle: "Fix ACP bridge", + kind: "direct", + updatedAt: 1_710_000_000_000, + thinkingLevel: "high", + modelProvider: "openai", + model: "gpt-5.4", + verboseLevel: "full", + reasoningLevel: "stream", + responseUsage: "tokens", + elevatedLevel: "ask", + totalTokens: 4096, + totalTokensFresh: true, + contextTokens: 8192, + }, + ], + }; + } + if (method === "sessions.get") { + return { + messages: [ + { role: "user", content: [{ type: "text", text: "Question" }] }, + { role: "assistant", content: [{ type: "text", text: "Answer" }] }, + { role: "system", content: [{ type: "text", text: "ignore me" }] }, + { role: "assistant", content: [{ type: "image", image: "skip" }] }, + ], + }; + } + return { ok: true }; + }) as GatewayClient["request"]; + const agent = new AcpGatewayAgent(connection, createAcpGateway(request), { + sessionStore, + }); + + const result = await agent.loadSession(createLoadSessionRequest("agent:main:work")); + + expect(result.modes?.currentModeId).toBe("high"); + expect(result.modes?.availableModes.map((mode) => mode.id)).toContain("xhigh"); + expect(result.configOptions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "thought_level", + currentValue: "high", + }), + expect.objectContaining({ + id: "verbose_level", + currentValue: "full", + }), + expect.objectContaining({ + id: "reasoning_level", + currentValue: "stream", + }), + expect.objectContaining({ + id: "response_usage", + currentValue: "tokens", + }), + expect.objectContaining({ + id: "elevated_level", + currentValue: "ask", + }), + ]), + ); + expect(sessionUpdate).toHaveBeenCalledWith({ + sessionId: "agent:main:work", + update: { + sessionUpdate: "user_message_chunk", + content: { type: "text", text: "Question" }, + }, + }); + expect(sessionUpdate).toHaveBeenCalledWith({ + sessionId: "agent:main:work", + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "Answer" }, + }, + }); + expect(sessionUpdate).toHaveBeenCalledWith({ + sessionId: "agent:main:work", + update: expect.objectContaining({ + sessionUpdate: "available_commands_update", + }), + }); + expect(sessionUpdate).toHaveBeenCalledWith({ + sessionId: "agent:main:work", + update: { + sessionUpdate: "session_info_update", + title: "Fix ACP bridge", + updatedAt: "2024-03-09T16:00:00.000Z", + }, + }); + expect(sessionUpdate).toHaveBeenCalledWith({ + sessionId: "agent:main:work", + update: { + sessionUpdate: "usage_update", + used: 4096, + size: 8192, + _meta: { + source: "gateway-session-store", + approximate: true, + }, + }, + }); + + sessionStore.clearAllSessionsForTest(); + }); +}); + describe("acp setSessionMode bridge behavior", () => { it("surfaces gateway mode patch failures instead of succeeding silently", async () => { const sessionStore = createInMemorySessionStore(); @@ -169,6 +360,278 @@ describe("acp setSessionMode bridge behavior", () => { sessionStore.clearAllSessionsForTest(); }); + + it("emits current mode and thought-level config updates after a successful mode change", async () => { + const sessionStore = createInMemorySessionStore(); + const connection = createAcpConnection(); + const sessionUpdate = connection.__sessionUpdateMock; + const request = vi.fn(async (method: string) => { + if (method === "sessions.list") { + return { + ts: Date.now(), + path: "/tmp/sessions.json", + count: 1, + defaults: { + modelProvider: null, + model: null, + contextTokens: null, + }, + sessions: [ + { + key: "mode-session", + kind: "direct", + updatedAt: Date.now(), + thinkingLevel: "high", + modelProvider: "openai", + model: "gpt-5.4", + }, + ], + }; + } + return { ok: true }; + }) as GatewayClient["request"]; + const agent = new AcpGatewayAgent(connection, createAcpGateway(request), { + sessionStore, + }); + + await agent.loadSession(createLoadSessionRequest("mode-session")); + sessionUpdate.mockClear(); + + await agent.setSessionMode(createSetSessionModeRequest("mode-session", "high")); + + expect(sessionUpdate).toHaveBeenCalledWith({ + sessionId: "mode-session", + update: { + sessionUpdate: "current_mode_update", + currentModeId: "high", + }, + }); + expect(sessionUpdate).toHaveBeenCalledWith({ + sessionId: "mode-session", + update: { + sessionUpdate: "config_option_update", + configOptions: expect.arrayContaining([ + expect.objectContaining({ + id: "thought_level", + currentValue: "high", + }), + ]), + }, + }); + + sessionStore.clearAllSessionsForTest(); + }); +}); + +describe("acp setSessionConfigOption bridge behavior", () => { + it("updates the thought-level config option and returns refreshed options", async () => { + const sessionStore = createInMemorySessionStore(); + const connection = createAcpConnection(); + const sessionUpdate = connection.__sessionUpdateMock; + const request = vi.fn(async (method: string) => { + if (method === "sessions.list") { + return { + ts: Date.now(), + path: "/tmp/sessions.json", + count: 1, + defaults: { + modelProvider: null, + model: null, + contextTokens: null, + }, + sessions: [ + { + key: "config-session", + kind: "direct", + updatedAt: Date.now(), + thinkingLevel: "minimal", + modelProvider: "openai", + model: "gpt-5.4", + }, + ], + }; + } + return { ok: true }; + }) as GatewayClient["request"]; + const agent = new AcpGatewayAgent(connection, createAcpGateway(request), { + sessionStore, + }); + + await agent.loadSession(createLoadSessionRequest("config-session")); + sessionUpdate.mockClear(); + + const result = await agent.setSessionConfigOption( + createSetSessionConfigOptionRequest("config-session", "thought_level", "minimal"), + ); + + expect(result.configOptions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "thought_level", + currentValue: "minimal", + }), + ]), + ); + expect(sessionUpdate).toHaveBeenCalledWith({ + sessionId: "config-session", + update: { + sessionUpdate: "current_mode_update", + currentModeId: "minimal", + }, + }); + expect(sessionUpdate).toHaveBeenCalledWith({ + sessionId: "config-session", + update: { + sessionUpdate: "config_option_update", + configOptions: expect.arrayContaining([ + expect.objectContaining({ + id: "thought_level", + currentValue: "minimal", + }), + ]), + }, + }); + + sessionStore.clearAllSessionsForTest(); + }); + + it("updates non-mode ACP config options through gateway session patches", async () => { + const sessionStore = createInMemorySessionStore(); + const connection = createAcpConnection(); + const sessionUpdate = connection.__sessionUpdateMock; + const request = vi.fn(async (method: string) => { + if (method === "sessions.list") { + return { + ts: Date.now(), + path: "/tmp/sessions.json", + count: 1, + defaults: { + modelProvider: null, + model: null, + contextTokens: null, + }, + sessions: [ + { + key: "reasoning-session", + kind: "direct", + updatedAt: Date.now(), + thinkingLevel: "minimal", + modelProvider: "openai", + model: "gpt-5.4", + reasoningLevel: "stream", + }, + ], + }; + } + return { ok: true }; + }) as GatewayClient["request"]; + const agent = new AcpGatewayAgent(connection, createAcpGateway(request), { + sessionStore, + }); + + await agent.loadSession(createLoadSessionRequest("reasoning-session")); + sessionUpdate.mockClear(); + + const result = await agent.setSessionConfigOption( + createSetSessionConfigOptionRequest("reasoning-session", "reasoning_level", "stream"), + ); + + expect(result.configOptions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "reasoning_level", + currentValue: "stream", + }), + ]), + ); + expect(sessionUpdate).toHaveBeenCalledWith({ + sessionId: "reasoning-session", + update: { + sessionUpdate: "config_option_update", + configOptions: expect.arrayContaining([ + expect.objectContaining({ + id: "reasoning_level", + currentValue: "stream", + }), + ]), + }, + }); + + sessionStore.clearAllSessionsForTest(); + }); +}); + +describe("acp session metadata and usage updates", () => { + it("emits a fresh usage snapshot after prompt completion when gateway totals are available", async () => { + const sessionStore = createInMemorySessionStore(); + const connection = createAcpConnection(); + const sessionUpdate = connection.__sessionUpdateMock; + const request = vi.fn(async (method: string) => { + if (method === "sessions.list") { + return { + ts: Date.now(), + path: "/tmp/sessions.json", + count: 1, + defaults: { + modelProvider: null, + model: null, + contextTokens: null, + }, + sessions: [ + { + key: "usage-session", + displayName: "Usage session", + kind: "direct", + updatedAt: 1_710_000_123_000, + thinkingLevel: "adaptive", + modelProvider: "openai", + model: "gpt-5.4", + totalTokens: 1200, + totalTokensFresh: true, + contextTokens: 4000, + }, + ], + }; + } + if (method === "chat.send") { + return new Promise(() => {}); + } + return { ok: true }; + }) as GatewayClient["request"]; + const agent = new AcpGatewayAgent(connection, createAcpGateway(request), { + sessionStore, + }); + + await agent.loadSession(createLoadSessionRequest("usage-session")); + sessionUpdate.mockClear(); + + const promptPromise = agent.prompt(createPromptRequest("usage-session", "hello")); + await agent.handleGatewayEvent(createChatFinalEvent("usage-session")); + await promptPromise; + + expect(sessionUpdate).toHaveBeenCalledWith({ + sessionId: "usage-session", + update: { + sessionUpdate: "session_info_update", + title: "Usage session", + updatedAt: "2024-03-09T16:02:03.000Z", + }, + }); + expect(sessionUpdate).toHaveBeenCalledWith({ + sessionId: "usage-session", + update: { + sessionUpdate: "usage_update", + used: 1200, + size: 4000, + _meta: { + source: "gateway-session-store", + approximate: true, + }, + }, + }); + + sessionStore.clearAllSessionsForTest(); + }); }); describe("acp prompt size hardening", () => { diff --git a/src/acp/translator.test-helpers.ts b/src/acp/translator.test-helpers.ts index c80918ba2cc..2bd7fd2747f 100644 --- a/src/acp/translator.test-helpers.ts +++ b/src/acp/translator.test-helpers.ts @@ -2,10 +2,16 @@ import type { AgentSideConnection } from "@agentclientprotocol/sdk"; import { vi } from "vitest"; import type { GatewayClient } from "../gateway/client.js"; -export function createAcpConnection(): AgentSideConnection { +export type TestAcpConnection = AgentSideConnection & { + __sessionUpdateMock: ReturnType; +}; + +export function createAcpConnection(): TestAcpConnection { + const sessionUpdate = vi.fn(async () => {}); return { - sessionUpdate: vi.fn(async () => {}), - } as unknown as AgentSideConnection; + sessionUpdate, + __sessionUpdateMock: sessionUpdate, + } as unknown as TestAcpConnection; } export function createAcpGateway( diff --git a/src/acp/translator.ts b/src/acp/translator.ts index 6be5f72510f..8628117b49c 100644 --- a/src/acp/translator.ts +++ b/src/acp/translator.ts @@ -16,14 +16,19 @@ import type { NewSessionResponse, PromptRequest, PromptResponse, + SessionConfigOption, + SessionModeState, + SetSessionConfigOptionRequest, + SetSessionConfigOptionResponse, SetSessionModeRequest, SetSessionModeResponse, StopReason, } from "@agentclientprotocol/sdk"; import { PROTOCOL_VERSION } from "@agentclientprotocol/sdk"; +import { listThinkingLevels } from "../auto-reply/thinking.js"; import type { GatewayClient } from "../gateway/client.js"; import type { EventFrame } from "../gateway/protocol/index.js"; -import type { SessionsListResult } from "../gateway/session-utils.js"; +import type { GatewaySessionRow, SessionsListResult } from "../gateway/session-utils.js"; import { createFixedWindowRateLimiter, type FixedWindowRateLimiter, @@ -34,7 +39,6 @@ import { extractAttachmentsFromPrompt, extractTextFromPrompt, formatToolTitle, - inferToolKind, } from "./event-mapper.js"; import { readBool, readNumber, readString } from "./meta.js"; import { parseSessionMeta, resetSessionIfNeeded, resolveSessionKey } from "./session-mapper.js"; @@ -43,6 +47,12 @@ import { ACP_AGENT_INFO, type AcpServerOptions } from "./types.js"; // Maximum allowed prompt size (2MB) to prevent DoS via memory exhaustion (CWE-400, GHSA-cxpw-2g23-2vgw) const MAX_PROMPT_BYTES = 2 * 1024 * 1024; +const ACP_THOUGHT_LEVEL_CONFIG_ID = "thought_level"; +const ACP_VERBOSE_LEVEL_CONFIG_ID = "verbose_level"; +const ACP_REASONING_LEVEL_CONFIG_ID = "reasoning_level"; +const ACP_RESPONSE_USAGE_CONFIG_ID = "response_usage"; +const ACP_ELEVATED_LEVEL_CONFIG_ID = "elevated_level"; +const ACP_LOAD_SESSION_REPLAY_LIMIT = 1_000_000; type PendingPrompt = { sessionId: string; @@ -59,9 +69,226 @@ type AcpGatewayAgentOptions = AcpServerOptions & { sessionStore?: AcpSessionStore; }; +type GatewaySessionPresentationRow = Pick< + GatewaySessionRow, + | "displayName" + | "label" + | "derivedTitle" + | "updatedAt" + | "thinkingLevel" + | "modelProvider" + | "model" + | "verboseLevel" + | "reasoningLevel" + | "responseUsage" + | "elevatedLevel" + | "totalTokens" + | "totalTokensFresh" + | "contextTokens" +>; + +type SessionPresentation = { + configOptions: SessionConfigOption[]; + modes: SessionModeState; +}; + +type SessionMetadata = { + title?: string | null; + updatedAt?: string | null; +}; + +type SessionUsageSnapshot = { + size: number; + used: number; +}; + +type SessionSnapshot = SessionPresentation & { + metadata?: SessionMetadata; + usage?: SessionUsageSnapshot; +}; + +type GatewayTranscriptMessage = { + role?: unknown; + content?: unknown; +}; + const SESSION_CREATE_RATE_LIMIT_DEFAULT_MAX_REQUESTS = 120; const SESSION_CREATE_RATE_LIMIT_DEFAULT_WINDOW_MS = 10_000; +function formatThinkingLevelName(level: string): string { + switch (level) { + case "xhigh": + return "Extra High"; + case "adaptive": + return "Adaptive"; + default: + return level.length > 0 ? `${level[0].toUpperCase()}${level.slice(1)}` : "Unknown"; + } +} + +function buildThinkingModeDescription(level: string): string | undefined { + if (level === "adaptive") { + return "Use the Gateway session default thought level."; + } + return undefined; +} + +function formatConfigValueName(value: string): string { + switch (value) { + case "xhigh": + return "Extra High"; + default: + return value.length > 0 ? `${value[0].toUpperCase()}${value.slice(1)}` : "Unknown"; + } +} + +function buildSelectConfigOption(params: { + id: string; + name: string; + description: string; + currentValue: string; + values: readonly string[]; + category?: string; +}): SessionConfigOption { + return { + type: "select", + id: params.id, + name: params.name, + category: params.category, + description: params.description, + currentValue: params.currentValue, + options: params.values.map((value) => ({ + value, + name: formatConfigValueName(value), + })), + }; +} + +function buildSessionPresentation(params: { + row?: GatewaySessionPresentationRow; + overrides?: Partial; +}): SessionPresentation { + const row = { + ...params.row, + ...params.overrides, + }; + const availableLevelIds: string[] = [...listThinkingLevels(row.modelProvider, row.model)]; + const currentModeId = row.thinkingLevel?.trim() || "adaptive"; + if (!availableLevelIds.includes(currentModeId)) { + availableLevelIds.push(currentModeId); + } + + const modes: SessionModeState = { + currentModeId, + availableModes: availableLevelIds.map((level) => ({ + id: level, + name: formatThinkingLevelName(level), + description: buildThinkingModeDescription(level), + })), + }; + + const configOptions: SessionConfigOption[] = [ + buildSelectConfigOption({ + id: ACP_THOUGHT_LEVEL_CONFIG_ID, + name: "Thought level", + category: "thought_level", + description: + "Controls how much deliberate reasoning OpenClaw requests from the Gateway model.", + currentValue: currentModeId, + values: availableLevelIds, + }), + buildSelectConfigOption({ + id: ACP_VERBOSE_LEVEL_CONFIG_ID, + name: "Tool verbosity", + description: + "Controls how much tool progress and output detail OpenClaw keeps enabled for the session.", + currentValue: row.verboseLevel?.trim() || "off", + values: ["off", "on", "full"], + }), + buildSelectConfigOption({ + id: ACP_REASONING_LEVEL_CONFIG_ID, + name: "Reasoning stream", + description: "Controls whether reasoning-capable models emit reasoning text for the session.", + currentValue: row.reasoningLevel?.trim() || "off", + values: ["off", "on", "stream"], + }), + buildSelectConfigOption({ + id: ACP_RESPONSE_USAGE_CONFIG_ID, + name: "Usage detail", + description: + "Controls how much usage information OpenClaw attaches to responses for the session.", + currentValue: row.responseUsage?.trim() || "off", + values: ["off", "tokens", "full"], + }), + buildSelectConfigOption({ + id: ACP_ELEVATED_LEVEL_CONFIG_ID, + name: "Elevated actions", + description: "Controls how aggressively the session allows elevated execution behavior.", + currentValue: row.elevatedLevel?.trim() || "off", + values: ["off", "on", "ask", "full"], + }), + ]; + + return { configOptions, modes }; +} + +function extractReplayText(content: unknown): string | undefined { + if (typeof content === "string") { + return content.length > 0 ? content : undefined; + } + if (!Array.isArray(content)) { + return undefined; + } + const text = content + .map((block) => { + if (!block || typeof block !== "object" || Array.isArray(block)) { + return ""; + } + const typedBlock = block as { type?: unknown; text?: unknown }; + return typedBlock.type === "text" && typeof typedBlock.text === "string" + ? typedBlock.text + : ""; + }) + .join(""); + return text.length > 0 ? text : undefined; +} + +function buildSessionMetadata(params: { + row?: GatewaySessionPresentationRow; + sessionKey: string; +}): SessionMetadata { + const title = + params.row?.derivedTitle?.trim() || + params.row?.displayName?.trim() || + params.row?.label?.trim() || + params.sessionKey; + const updatedAt = + typeof params.row?.updatedAt === "number" && Number.isFinite(params.row.updatedAt) + ? new Date(params.row.updatedAt).toISOString() + : null; + return { title, updatedAt }; +} + +function buildSessionUsageSnapshot( + row?: GatewaySessionPresentationRow, +): SessionUsageSnapshot | undefined { + const totalTokens = row?.totalTokens; + const contextTokens = row?.contextTokens; + if ( + row?.totalTokensFresh !== true || + typeof totalTokens !== "number" || + !Number.isFinite(totalTokens) || + typeof contextTokens !== "number" || + !Number.isFinite(contextTokens) || + contextTokens <= 0 + ) { + return undefined; + } + const size = Math.max(0, Math.floor(contextTokens)); + const used = Math.max(0, Math.min(Math.floor(totalTokens), size)); + return { size, used }; +} + function buildSystemInputProvenance(originSessionId: string) { return { kind: "external_user" as const, @@ -186,8 +413,17 @@ export class AcpGatewayAgent implements Agent { cwd: params.cwd, }); this.log(`newSession: ${session.sessionId} -> ${session.sessionKey}`); + const sessionSnapshot = await this.getSessionSnapshot(session.sessionKey); + await this.sendSessionSnapshotUpdate(session.sessionId, sessionSnapshot, { + includeControls: false, + }); await this.sendAvailableCommands(session.sessionId); - return { sessionId: session.sessionId }; + const { configOptions, modes } = sessionSnapshot; + return { + sessionId: session.sessionId, + configOptions, + modes, + }; } async loadSession(params: LoadSessionRequest): Promise { @@ -208,8 +444,17 @@ export class AcpGatewayAgent implements Agent { cwd: params.cwd, }); this.log(`loadSession: ${session.sessionId} -> ${session.sessionKey}`); + const [sessionSnapshot, transcript] = await Promise.all([ + this.getSessionSnapshot(session.sessionKey), + this.getSessionTranscript(session.sessionKey), + ]); + await this.replaySessionTranscript(session.sessionId, transcript); + await this.sendSessionSnapshotUpdate(session.sessionId, sessionSnapshot, { + includeControls: false, + }); await this.sendAvailableCommands(session.sessionId); - return {}; + const { configOptions, modes } = sessionSnapshot; + return { configOptions, modes }; } async unstable_listSessions(params: ListSessionsRequest): Promise { @@ -250,6 +495,12 @@ export class AcpGatewayAgent implements Agent { thinkingLevel: params.modeId, }); this.log(`setSessionMode: ${session.sessionId} -> ${params.modeId}`); + const sessionSnapshot = await this.getSessionSnapshot(session.sessionKey, { + thinkingLevel: params.modeId, + }); + await this.sendSessionSnapshotUpdate(session.sessionId, sessionSnapshot, { + includeControls: true, + }); } catch (err) { this.log(`setSessionMode error: ${String(err)}`); throw err instanceof Error ? err : new Error(String(err)); @@ -257,6 +508,39 @@ export class AcpGatewayAgent implements Agent { return {}; } + async setSessionConfigOption( + params: SetSessionConfigOptionRequest, + ): Promise { + const session = this.sessionStore.getSession(params.sessionId); + if (!session) { + throw new Error(`Session ${params.sessionId} not found`); + } + const sessionPatch = this.resolveSessionConfigPatch(params.configId, params.value); + + try { + await this.gateway.request("sessions.patch", { + key: session.sessionKey, + ...sessionPatch.patch, + }); + this.log( + `setSessionConfigOption: ${session.sessionId} -> ${params.configId}=${params.value}`, + ); + const sessionSnapshot = await this.getSessionSnapshot( + session.sessionKey, + sessionPatch.overrides, + ); + await this.sendSessionSnapshotUpdate(session.sessionId, sessionSnapshot, { + includeControls: true, + }); + return { + configOptions: sessionSnapshot.configOptions, + }; + } catch (err) { + this.log(`setSessionConfigOption error: ${String(err)}`); + throw err instanceof Error ? err : new Error(String(err)); + } + } + async prompt(params: PromptRequest): Promise { const session = this.sessionStore.getSession(params.sessionId); if (!session) { @@ -412,7 +696,6 @@ export class AcpGatewayAgent implements Agent { title: formatToolTitle(name, args), status: "in_progress", rawInput: args, - kind: inferToolKind(name), }, }); return; @@ -420,6 +703,7 @@ export class AcpGatewayAgent implements Agent { if (phase === "result") { const isError = Boolean(data.isError); + pending.toolCalls?.delete(toolCallId); await this.connection.sessionUpdate({ sessionId: pending.sessionId, update: { @@ -462,11 +746,11 @@ export class AcpGatewayAgent implements Agent { if (state === "final") { const rawStopReason = payload.stopReason as string | undefined; const stopReason: StopReason = rawStopReason === "max_tokens" ? "max_tokens" : "end_turn"; - this.finishPrompt(pending.sessionId, pending, stopReason); + await this.finishPrompt(pending.sessionId, pending, stopReason); return; } if (state === "aborted") { - this.finishPrompt(pending.sessionId, pending, "cancelled"); + await this.finishPrompt(pending.sessionId, pending, "cancelled"); return; } if (state === "error") { @@ -474,7 +758,7 @@ export class AcpGatewayAgent implements Agent { // do not treat transient backend errors (timeouts, rate-limits) as deliberate // refusals. TODO: when ChatEventSchema gains a structured errorKind field // (e.g. "refusal" | "timeout" | "rate_limit"), use it to distinguish here. - this.finishPrompt(pending.sessionId, pending, "end_turn"); + void this.finishPrompt(pending.sessionId, pending, "end_turn"); } } @@ -507,9 +791,17 @@ export class AcpGatewayAgent implements Agent { }); } - private finishPrompt(sessionId: string, pending: PendingPrompt, stopReason: StopReason): void { + private async finishPrompt( + sessionId: string, + pending: PendingPrompt, + stopReason: StopReason, + ): Promise { this.pendingPrompts.delete(sessionId); this.sessionStore.clearActiveRun(sessionId); + const sessionSnapshot = await this.getSessionSnapshot(pending.sessionKey); + await this.sendSessionSnapshotUpdate(sessionId, sessionSnapshot, { + includeControls: false, + }); pending.resolve({ stopReason }); } @@ -532,6 +824,174 @@ export class AcpGatewayAgent implements Agent { }); } + private async getSessionSnapshot( + sessionKey: string, + overrides?: Partial, + ): Promise { + try { + const row = await this.getGatewaySessionRow(sessionKey); + return { + ...buildSessionPresentation({ row, overrides }), + metadata: buildSessionMetadata({ row, sessionKey }), + usage: buildSessionUsageSnapshot(row), + }; + } catch (err) { + this.log(`session presentation fallback for ${sessionKey}: ${String(err)}`); + return { + ...buildSessionPresentation({ overrides }), + metadata: buildSessionMetadata({ sessionKey }), + }; + } + } + + private async getGatewaySessionRow( + sessionKey: string, + ): Promise { + const result = await this.gateway.request("sessions.list", { + limit: 200, + search: sessionKey, + includeDerivedTitles: true, + }); + const session = result.sessions.find((entry) => entry.key === sessionKey); + if (!session) { + return undefined; + } + return { + displayName: session.displayName, + label: session.label, + derivedTitle: session.derivedTitle, + updatedAt: session.updatedAt, + thinkingLevel: session.thinkingLevel, + modelProvider: session.modelProvider, + model: session.model, + verboseLevel: session.verboseLevel, + reasoningLevel: session.reasoningLevel, + responseUsage: session.responseUsage, + elevatedLevel: session.elevatedLevel, + totalTokens: session.totalTokens, + totalTokensFresh: session.totalTokensFresh, + contextTokens: session.contextTokens, + }; + } + + private resolveSessionConfigPatch( + configId: string, + value: string, + ): { + overrides: Partial; + patch: Record; + } { + switch (configId) { + case ACP_THOUGHT_LEVEL_CONFIG_ID: + return { + patch: { thinkingLevel: value }, + overrides: { thinkingLevel: value }, + }; + case ACP_VERBOSE_LEVEL_CONFIG_ID: + return { + patch: { verboseLevel: value }, + overrides: { verboseLevel: value }, + }; + case ACP_REASONING_LEVEL_CONFIG_ID: + return { + patch: { reasoningLevel: value }, + overrides: { reasoningLevel: value }, + }; + case ACP_RESPONSE_USAGE_CONFIG_ID: + return { + patch: { responseUsage: value }, + overrides: { responseUsage: value as GatewaySessionPresentationRow["responseUsage"] }, + }; + case ACP_ELEVATED_LEVEL_CONFIG_ID: + return { + patch: { elevatedLevel: value }, + overrides: { elevatedLevel: value }, + }; + default: + throw new Error(`ACP bridge mode does not support session config option "${configId}".`); + } + } + + private async getSessionTranscript(sessionKey: string): Promise { + const result = await this.gateway.request<{ messages?: unknown[] }>("sessions.get", { + key: sessionKey, + limit: ACP_LOAD_SESSION_REPLAY_LIMIT, + }); + if (!Array.isArray(result.messages)) { + return []; + } + return result.messages as GatewayTranscriptMessage[]; + } + + private async replaySessionTranscript( + sessionId: string, + transcript: ReadonlyArray, + ): Promise { + for (const message of transcript) { + const role = typeof message.role === "string" ? message.role : ""; + if (role !== "user" && role !== "assistant") { + continue; + } + const text = extractReplayText(message.content); + if (!text) { + continue; + } + await this.connection.sessionUpdate({ + sessionId, + update: { + sessionUpdate: role === "user" ? "user_message_chunk" : "agent_message_chunk", + content: { type: "text", text }, + }, + }); + } + } + + private async sendSessionSnapshotUpdate( + sessionId: string, + sessionSnapshot: SessionSnapshot, + options: { includeControls: boolean }, + ): Promise { + if (options.includeControls) { + await this.connection.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "current_mode_update", + currentModeId: sessionSnapshot.modes.currentModeId, + }, + }); + await this.connection.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "config_option_update", + configOptions: sessionSnapshot.configOptions, + }, + }); + } + if (sessionSnapshot.metadata) { + await this.connection.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "session_info_update", + ...sessionSnapshot.metadata, + }, + }); + } + if (sessionSnapshot.usage) { + await this.connection.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "usage_update", + used: sessionSnapshot.usage.used, + size: sessionSnapshot.usage.size, + _meta: { + source: "gateway-session-store", + approximate: true, + }, + }, + }); + } + } + private assertSupportedSessionSetup(mcpServers: ReadonlyArray): void { if (mcpServers.length === 0) { return;