| 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"; |
|
|
| function createNewSessionRequest(cwd = "/tmp"): NewSessionRequest { |
| return { |
| cwd, |
| mcpServers: [], |
| _meta: {}, |
| } as unknown as NewSessionRequest; |
| } |
|
|
| function createLoadSessionRequest(sessionId: string, cwd = "/tmp"): LoadSessionRequest { |
| return { |
| sessionId, |
| cwd, |
| mcpServers: [], |
| _meta: {}, |
| } as unknown as LoadSessionRequest; |
| } |
|
|
| function createPromptRequest( |
| sessionId: string, |
| text: string, |
| meta: Record<string, unknown> = {}, |
| ): PromptRequest { |
| return { |
| sessionId, |
| prompt: [{ type: "text", text }], |
| _meta: meta, |
| } as unknown as PromptRequest; |
| } |
|
|
| function createSetSessionModeRequest(sessionId: string, modeId: string): SetSessionModeRequest { |
| return { |
| sessionId, |
| modeId, |
| _meta: {}, |
| } as unknown as SetSessionModeRequest; |
| } |
|
|
| function createSetSessionConfigOptionRequest( |
| sessionId: string, |
| configId: string, |
| value: string | boolean, |
| ): SetSessionConfigOptionRequest { |
| return { |
| sessionId, |
| configId, |
| value, |
| _meta: {}, |
| } as unknown as SetSessionConfigOptionRequest; |
| } |
|
|
| function createToolEvent(params: { |
| sessionKey: string; |
| phase: "start" | "update" | "result"; |
| toolCallId: string; |
| name: string; |
| args?: Record<string, unknown>; |
| partialResult?: unknown; |
| result?: unknown; |
| isError?: boolean; |
| }): EventFrame { |
| return { |
| event: "agent", |
| payload: { |
| sessionKey: params.sessionKey, |
| stream: "tool", |
| data: { |
| phase: params.phase, |
| toolCallId: params.toolCallId, |
| name: params.name, |
| args: params.args, |
| partialResult: params.partialResult, |
| result: params.result, |
| isError: params.isError, |
| }, |
| }, |
| } as unknown as EventFrame; |
| } |
|
|
| 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(); |
| const agent = new AcpGatewayAgent(createAcpConnection(), createAcpGateway(request), { |
| sessionStore, |
| }); |
| await agent.loadSession(createLoadSessionRequest(params.sessionId)); |
|
|
| await expect(agent.prompt(createPromptRequest(params.sessionId, params.text))).rejects.toThrow( |
| /maximum allowed size/i, |
| ); |
| expect(request).not.toHaveBeenCalledWith("chat.send", expect.anything(), expect.anything()); |
| const session = sessionStore.getSession(params.sessionId); |
| expect(session?.activeRunId).toBeNull(); |
| expect(session?.abortController).toBeNull(); |
|
|
| sessionStore.clearAllSessionsForTest(); |
| } |
|
|
| describe("acp session creation rate limit", () => { |
| it("rate limits excessive newSession bursts", async () => { |
| const sessionStore = createInMemorySessionStore(); |
| const agent = new AcpGatewayAgent(createAcpConnection(), createAcpGateway(), { |
| sessionStore, |
| sessionCreateRateLimit: { |
| maxRequests: 2, |
| windowMs: 60_000, |
| }, |
| }); |
|
|
| await agent.newSession(createNewSessionRequest()); |
| await agent.newSession(createNewSessionRequest()); |
| await expect(agent.newSession(createNewSessionRequest())).rejects.toThrow( |
| /session creation rate limit exceeded/i, |
| ); |
|
|
| sessionStore.clearAllSessionsForTest(); |
| }); |
|
|
| it("does not count loadSession refreshes for an existing session ID", async () => { |
| const sessionStore = createInMemorySessionStore(); |
| const agent = new AcpGatewayAgent(createAcpConnection(), createAcpGateway(), { |
| sessionStore, |
| sessionCreateRateLimit: { |
| maxRequests: 1, |
| windowMs: 60_000, |
| }, |
| }); |
|
|
| await agent.loadSession(createLoadSessionRequest("shared-session")); |
| await agent.loadSession(createLoadSessionRequest("shared-session")); |
| await expect(agent.loadSession(createLoadSessionRequest("new-session"))).rejects.toThrow( |
| /session creation rate limit exceeded/i, |
| ); |
|
|
| sessionStore.clearAllSessionsForTest(); |
| }); |
| }); |
|
|
| describe("acp unsupported bridge session setup", () => { |
| it("rejects per-session MCP servers on newSession", async () => { |
| const sessionStore = createInMemorySessionStore(); |
| const connection = createAcpConnection(); |
| const sessionUpdate = connection.__sessionUpdateMock; |
| const agent = new AcpGatewayAgent(connection, createAcpGateway(), { |
| sessionStore, |
| }); |
|
|
| await expect( |
| agent.newSession({ |
| ...createNewSessionRequest(), |
| mcpServers: [{ name: "docs", command: "mcp-docs" }] as never[], |
| }), |
| ).rejects.toThrow(/does not support per-session MCP servers/i); |
|
|
| expect(sessionStore.hasSession("docs-session")).toBe(false); |
| expect(sessionUpdate).not.toHaveBeenCalled(); |
| sessionStore.clearAllSessionsForTest(); |
| }); |
|
|
| it("rejects per-session MCP servers on loadSession", async () => { |
| const sessionStore = createInMemorySessionStore(); |
| const connection = createAcpConnection(); |
| const sessionUpdate = connection.__sessionUpdateMock; |
| const agent = new AcpGatewayAgent(connection, createAcpGateway(), { |
| sessionStore, |
| }); |
|
|
| await expect( |
| agent.loadSession({ |
| ...createLoadSessionRequest("docs-session"), |
| mcpServers: [{ name: "docs", command: "mcp-docs" }] as never[], |
| }), |
| ).rejects.toThrow(/does not support per-session MCP servers/i); |
|
|
| expect(sessionStore.hasSession("docs-session")).toBe(false); |
| expect(sessionUpdate).not.toHaveBeenCalled(); |
| sessionStore.clearAllSessionsForTest(); |
| }); |
| }); |
|
|
| 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(); |
| }); |
|
|
| it("replays assistant thinking blocks on loadSession when transcript contains them", 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:thinking", |
| label: "main-thinking", |
| displayName: "Main thinking", |
| derivedTitle: "Inspect reasoning", |
| kind: "direct", |
| updatedAt: 1_710_000_000_000, |
| thinkingLevel: "high", |
| }, |
| ], |
| }; |
| } |
| if (method === "sessions.get") { |
| return { |
| messages: [ |
| { |
| role: "assistant", |
| content: [ |
| { type: "thinking", thinking: "Hidden chain" }, |
| { type: "text", text: "Visible answer" }, |
| ], |
| }, |
| ], |
| }; |
| } |
| return { ok: true }; |
| }) as GatewayClient["request"]; |
| const agent = new AcpGatewayAgent(connection, createAcpGateway(request), { |
| sessionStore, |
| }); |
|
|
| await agent.loadSession(createLoadSessionRequest("agent:main:thinking")); |
|
|
| expect(sessionUpdate).toHaveBeenCalledWith({ |
| sessionId: "agent:main:thinking", |
| update: { |
| sessionUpdate: "agent_thought_chunk", |
| content: { type: "text", text: "Hidden chain" }, |
| }, |
| }); |
| expect(sessionUpdate).toHaveBeenCalledWith({ |
| sessionId: "agent:main:thinking", |
| update: { |
| sessionUpdate: "agent_message_chunk", |
| content: { type: "text", text: "Visible answer" }, |
| }, |
| }); |
|
|
| sessionStore.clearAllSessionsForTest(); |
| }); |
|
|
| it("falls back to an empty transcript when sessions.get fails during loadSession", 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:recover", |
| label: "recover", |
| displayName: "Recover session", |
| kind: "direct", |
| updatedAt: 1_710_000_000_000, |
| thinkingLevel: "adaptive", |
| modelProvider: "openai", |
| model: "gpt-5.4", |
| }, |
| ], |
| }; |
| } |
| if (method === "sessions.get") { |
| throw new Error("sessions.get unavailable"); |
| } |
| return { ok: true }; |
| }) as GatewayClient["request"]; |
| const agent = new AcpGatewayAgent(connection, createAcpGateway(request), { |
| sessionStore, |
| }); |
|
|
| const result = await agent.loadSession(createLoadSessionRequest("agent:main:recover")); |
|
|
| expect(result.modes?.currentModeId).toBe("adaptive"); |
| expect(sessionUpdate).toHaveBeenCalledWith({ |
| sessionId: "agent:main:recover", |
| update: expect.objectContaining({ |
| sessionUpdate: "available_commands_update", |
| }), |
| }); |
| expect(sessionUpdate).not.toHaveBeenCalledWith({ |
| sessionId: "agent:main:recover", |
| update: expect.objectContaining({ |
| sessionUpdate: "user_message_chunk", |
| }), |
| }); |
|
|
| sessionStore.clearAllSessionsForTest(); |
| }); |
| }); |
|
|
| describe("acp setSessionMode bridge behavior", () => { |
| it("surfaces gateway mode patch failures instead of succeeding silently", async () => { |
| const sessionStore = createInMemorySessionStore(); |
| const request = vi.fn(async (method: string) => { |
| if (method === "sessions.patch") { |
| throw new Error("gateway rejected mode"); |
| } |
| return { ok: true }; |
| }) as GatewayClient["request"]; |
| const agent = new AcpGatewayAgent(createAcpConnection(), createAcpGateway(request), { |
| sessionStore, |
| }); |
|
|
| await agent.loadSession(createLoadSessionRequest("mode-session")); |
|
|
| await expect( |
| agent.setSessionMode(createSetSessionModeRequest("mode-session", "high")), |
| ).rejects.toThrow(/gateway rejected mode/i); |
|
|
| 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(); |
| }); |
|
|
| it("updates fast 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, params?: unknown) => { |
| if (method === "sessions.list") { |
| return { |
| ts: Date.now(), |
| path: "/tmp/sessions.json", |
| count: 1, |
| defaults: { |
| modelProvider: null, |
| model: null, |
| contextTokens: null, |
| }, |
| sessions: [ |
| { |
| key: "fast-session", |
| kind: "direct", |
| updatedAt: Date.now(), |
| thinkingLevel: "minimal", |
| modelProvider: "openai", |
| model: "gpt-5.4", |
| fastMode: true, |
| }, |
| ], |
| }; |
| } |
| if (method === "sessions.patch") { |
| expect(params).toEqual({ |
| key: "fast-session", |
| fastMode: true, |
| }); |
| } |
| return { ok: true }; |
| }) as GatewayClient["request"]; |
| const agent = new AcpGatewayAgent(connection, createAcpGateway(request), { |
| sessionStore, |
| }); |
|
|
| await agent.loadSession(createLoadSessionRequest("fast-session")); |
| sessionUpdate.mockClear(); |
|
|
| const result = await agent.setSessionConfigOption( |
| createSetSessionConfigOptionRequest("fast-session", "fast_mode", "on"), |
| ); |
|
|
| expect(result.configOptions).toEqual( |
| expect.arrayContaining([ |
| expect.objectContaining({ |
| id: "fast_mode", |
| currentValue: "on", |
| }), |
| ]), |
| ); |
| expect(sessionUpdate).toHaveBeenCalledWith({ |
| sessionId: "fast-session", |
| update: { |
| sessionUpdate: "config_option_update", |
| configOptions: expect.arrayContaining([ |
| expect.objectContaining({ |
| id: "fast_mode", |
| currentValue: "on", |
| }), |
| ]), |
| }, |
| }); |
|
|
| sessionStore.clearAllSessionsForTest(); |
| }); |
|
|
| it("rejects non-string ACP config option values", async () => { |
| const sessionStore = createInMemorySessionStore(); |
| const connection = createAcpConnection(); |
| 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: "bool-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("bool-config-session")); |
|
|
| await expect( |
| agent.setSessionConfigOption( |
| createSetSessionConfigOptionRequest("bool-config-session", "thought_level", false), |
| ), |
| ).rejects.toThrow( |
| 'ACP bridge does not support non-string session config option values for "thought_level".', |
| ); |
| expect(request).not.toHaveBeenCalledWith( |
| "sessions.patch", |
| expect.objectContaining({ key: "bool-config-session" }), |
| ); |
|
|
| sessionStore.clearAllSessionsForTest(); |
| }); |
| }); |
|
|
| describe("acp tool streaming bridge behavior", () => { |
| it("maps Gateway tool partial output and file locations into ACP tool updates", async () => { |
| const sessionStore = createInMemorySessionStore(); |
| const connection = createAcpConnection(); |
| const sessionUpdate = connection.__sessionUpdateMock; |
| const request = vi.fn(async (method: string) => { |
| 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("tool-session")); |
| sessionUpdate.mockClear(); |
|
|
| const promptPromise = agent.prompt(createPromptRequest("tool-session", "Inspect app.ts")); |
|
|
| await agent.handleGatewayEvent( |
| createToolEvent({ |
| sessionKey: "tool-session", |
| phase: "start", |
| toolCallId: "tool-1", |
| name: "read", |
| args: { path: "src/app.ts", line: 12 }, |
| }), |
| ); |
| await agent.handleGatewayEvent( |
| createToolEvent({ |
| sessionKey: "tool-session", |
| phase: "update", |
| toolCallId: "tool-1", |
| name: "read", |
| partialResult: { |
| content: [{ type: "text", text: "partial output" }], |
| details: { path: "src/app.ts" }, |
| }, |
| }), |
| ); |
| await agent.handleGatewayEvent( |
| createToolEvent({ |
| sessionKey: "tool-session", |
| phase: "result", |
| toolCallId: "tool-1", |
| name: "read", |
| result: { |
| content: [{ type: "text", text: "FILE:src/app.ts" }], |
| details: { path: "src/app.ts" }, |
| }, |
| }), |
| ); |
| await agent.handleGatewayEvent(createChatFinalEvent("tool-session")); |
| await promptPromise; |
|
|
| expect(sessionUpdate).toHaveBeenCalledWith({ |
| sessionId: "tool-session", |
| update: { |
| sessionUpdate: "tool_call", |
| toolCallId: "tool-1", |
| title: "read: path: src/app.ts, line: 12", |
| status: "in_progress", |
| rawInput: { path: "src/app.ts", line: 12 }, |
| kind: "read", |
| locations: [{ path: "src/app.ts", line: 12 }], |
| }, |
| }); |
| expect(sessionUpdate).toHaveBeenCalledWith({ |
| sessionId: "tool-session", |
| update: { |
| sessionUpdate: "tool_call_update", |
| toolCallId: "tool-1", |
| status: "in_progress", |
| rawOutput: { |
| content: [{ type: "text", text: "partial output" }], |
| details: { path: "src/app.ts" }, |
| }, |
| content: [ |
| { |
| type: "content", |
| content: { type: "text", text: "partial output" }, |
| }, |
| ], |
| locations: [{ path: "src/app.ts", line: 12 }], |
| }, |
| }); |
| expect(sessionUpdate).toHaveBeenCalledWith({ |
| sessionId: "tool-session", |
| update: { |
| sessionUpdate: "tool_call_update", |
| toolCallId: "tool-1", |
| status: "completed", |
| rawOutput: { |
| content: [{ type: "text", text: "FILE:src/app.ts" }], |
| details: { path: "src/app.ts" }, |
| }, |
| content: [ |
| { |
| type: "content", |
| content: { type: "text", text: "FILE:src/app.ts" }, |
| }, |
| ], |
| locations: [{ path: "src/app.ts", line: 12 }], |
| }, |
| }); |
|
|
| 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(); |
| }); |
|
|
| it("still resolves prompts when snapshot updates fail after completion", 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(); |
| sessionUpdate.mockRejectedValueOnce(new Error("session update transport failed")); |
|
|
| const promptPromise = agent.prompt(createPromptRequest("usage-session", "hello")); |
| await agent.handleGatewayEvent(createChatFinalEvent("usage-session")); |
|
|
| await expect(promptPromise).resolves.toEqual({ stopReason: "end_turn" }); |
| const session = sessionStore.getSession("usage-session"); |
| expect(session?.activeRunId).toBeNull(); |
| expect(session?.abortController).toBeNull(); |
|
|
| sessionStore.clearAllSessionsForTest(); |
| }); |
| }); |
|
|
| describe("acp prompt size hardening", () => { |
| it("rejects oversized prompt blocks without leaking active runs", async () => { |
| await expectOversizedPromptRejected({ |
| sessionId: "prompt-limit-oversize", |
| text: "a".repeat(2 * 1024 * 1024 + 1), |
| }); |
| }); |
|
|
| it("rejects oversize final messages from cwd prefix without leaking active runs", async () => { |
| await expectOversizedPromptRejected({ |
| sessionId: "prompt-limit-prefix", |
| text: "a".repeat(2 * 1024 * 1024), |
| }); |
| }); |
| }); |
|
|
| describe("acp final chat snapshots", () => { |
| async function createSnapshotHarness() { |
| const sessionStore = createInMemorySessionStore(); |
| const connection = createAcpConnection(); |
| const sessionUpdate = connection.__sessionUpdateMock; |
| const request = vi.fn(async (method: string) => { |
| 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("snapshot-session")); |
| sessionUpdate.mockClear(); |
| const promptPromise = agent.prompt(createPromptRequest("snapshot-session", "hello")); |
| const runId = sessionStore.getSession("snapshot-session")?.activeRunId; |
| if (!runId) { |
| throw new Error("Expected ACP prompt run to be active"); |
| } |
| return { agent, sessionUpdate, promptPromise, runId, sessionStore }; |
| } |
|
|
| it("emits final snapshot text before resolving end_turn", async () => { |
| const { agent, sessionUpdate, promptPromise, runId, sessionStore } = |
| await createSnapshotHarness(); |
|
|
| await agent.handleGatewayEvent({ |
| event: "chat", |
| payload: { |
| sessionKey: "snapshot-session", |
| runId, |
| state: "final", |
| stopReason: "end_turn", |
| message: { |
| content: [{ type: "text", text: "FINAL TEXT SHOULD BE EMITTED" }], |
| }, |
| }, |
| } as unknown as EventFrame); |
|
|
| await expect(promptPromise).resolves.toEqual({ stopReason: "end_turn" }); |
| expect(sessionUpdate).toHaveBeenCalledWith({ |
| sessionId: "snapshot-session", |
| update: { |
| sessionUpdate: "agent_message_chunk", |
| content: { type: "text", text: "FINAL TEXT SHOULD BE EMITTED" }, |
| }, |
| }); |
| expect(sessionStore.getSession("snapshot-session")?.activeRunId).toBeNull(); |
| sessionStore.clearAllSessionsForTest(); |
| }); |
|
|
| it("does not duplicate text when final repeats the last delta snapshot", async () => { |
| const { agent, sessionUpdate, promptPromise, runId, sessionStore } = |
| await createSnapshotHarness(); |
|
|
| await agent.handleGatewayEvent({ |
| event: "chat", |
| payload: { |
| sessionKey: "snapshot-session", |
| runId, |
| state: "delta", |
| message: { |
| content: [{ type: "text", text: "Hello world" }], |
| }, |
| }, |
| } as unknown as EventFrame); |
|
|
| await agent.handleGatewayEvent({ |
| event: "chat", |
| payload: { |
| sessionKey: "snapshot-session", |
| runId, |
| state: "final", |
| stopReason: "end_turn", |
| message: { |
| content: [{ type: "text", text: "Hello world" }], |
| }, |
| }, |
| } as unknown as EventFrame); |
|
|
| await expect(promptPromise).resolves.toEqual({ stopReason: "end_turn" }); |
| const chunks = sessionUpdate.mock.calls.filter( |
| (call: unknown[]) => |
| (call[0] as Record<string, unknown>)?.update && |
| (call[0] as Record<string, Record<string, unknown>>).update?.sessionUpdate === |
| "agent_message_chunk", |
| ); |
| expect(chunks).toHaveLength(1); |
| sessionStore.clearAllSessionsForTest(); |
| }); |
|
|
| it("emits only the missing tail when the final snapshot extends prior deltas", async () => { |
| const { agent, sessionUpdate, promptPromise, runId, sessionStore } = |
| await createSnapshotHarness(); |
|
|
| await agent.handleGatewayEvent({ |
| event: "chat", |
| payload: { |
| sessionKey: "snapshot-session", |
| runId, |
| state: "delta", |
| message: { |
| content: [{ type: "text", text: "Hello" }], |
| }, |
| }, |
| } as unknown as EventFrame); |
|
|
| await agent.handleGatewayEvent({ |
| event: "chat", |
| payload: { |
| sessionKey: "snapshot-session", |
| runId, |
| state: "final", |
| stopReason: "max_tokens", |
| message: { |
| content: [{ type: "text", text: "Hello world" }], |
| }, |
| }, |
| } as unknown as EventFrame); |
|
|
| await expect(promptPromise).resolves.toEqual({ stopReason: "max_tokens" }); |
| expect(sessionUpdate).toHaveBeenCalledWith({ |
| sessionId: "snapshot-session", |
| update: { |
| sessionUpdate: "agent_message_chunk", |
| content: { type: "text", text: "Hello" }, |
| }, |
| }); |
| expect(sessionUpdate).toHaveBeenCalledWith({ |
| sessionId: "snapshot-session", |
| update: { |
| sessionUpdate: "agent_message_chunk", |
| content: { type: "text", text: " world" }, |
| }, |
| }); |
| sessionStore.clearAllSessionsForTest(); |
| }); |
| }); |
|
|