mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
46
docs.acp.md
46
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:<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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user