acp: restore session context and controls (#41425)

Merged via squash.

Prepared head SHA: fcabdf7c31e33bbbd3ef82bdee92755eb0f62c82
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
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

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