acp: restore session context and controls (#41425)

Merged via squash.

Prepared head SHA: fcabdf7c31
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
This commit is contained in:
Mariano
2026-03-09 22:17:19 +01:00
committed by GitHub
parent e6e4169e82
commit d346f2d9ce
6 changed files with 1003 additions and 47 deletions

View File

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

View File

@@ -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:<uuid>` 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

View File

@@ -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:<uuid>` 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:<uuid>` 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

View File

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

View File

@@ -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<typeof vi.fn>;
};
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(

View File

@@ -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<GatewaySessionPresentationRow>;
}): 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<LoadSessionResponse> {
@@ -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<ListSessionsResponse> {
@@ -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<SetSessionConfigOptionResponse> {
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<PromptResponse> {
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<void> {
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<GatewaySessionPresentationRow>,
): Promise<SessionSnapshot> {
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<GatewaySessionPresentationRow | undefined> {
const result = await this.gateway.request<SessionsListResult>("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<GatewaySessionPresentationRow>;
patch: Record<string, string>;
} {
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<GatewayTranscriptMessage[]> {
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<GatewayTranscriptMessage>,
): Promise<void> {
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<void> {
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<unknown>): void {
if (mcpServers.length === 0) {
return;