From 1fe7ec09a3a7bcb7501231dae4b0ffe6ef9b70a4 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Thu, 25 Jun 2026 13:45:50 +0200 Subject: [PATCH] feat: Push session title updates at turn end (#812) --- src/acp-agent.ts | 46 +++++++++++++++++ src/tests/acp-agent.test.ts | 100 ++++++++++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+) diff --git a/src/acp-agent.ts b/src/acp-agent.ts index d926c7ad..8f19b9c8 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -50,6 +50,7 @@ import { AgentInfo, CanUseTool, deleteSession, + getSessionInfo, getSessionMessages, listSessions, McpServerConfig, @@ -262,6 +263,12 @@ type Session = { /** Accumulated task list for the session, keyed by task ID. Task IDs are * per-session, so this state must not be shared across sessions. */ taskState: TaskState; + /** Last session title we pushed to the client via `session_info_update`. + * The SDK auto-generates a title in a background task and persists it to the + * session file; we poll it on each turn-end (`session_state_changed: idle`) + * and only notify the client when it actually changes. Undefined until the + * first title is observed. */ + lastTitle?: string; /** Caches `tool_use` blocks by id so the matching `tool_result` can recover * the tool name/input when mapping it to a `tool_call_update`. Per-session * (tool_use ids are only unique within a session) and pruned at @@ -943,6 +950,40 @@ export class ClaudeAcpAgent { }; } + /** Read the SDK-maintained title for a session and, if it changed since the + * last time we looked, notify the client with a `session_info_update`. The + * SDK has no push event for the title it auto-generates in the background, so + * we pull it at turn-end. A missing session file or read error is non-fatal: + * the title is best-effort and another turn will retry. */ + private async maybeUpdateSessionTitle(sessionId: string, session: Session): Promise { + let info; + try { + info = await getSessionInfo(sessionId, { dir: session.cwd }); + } catch (error) { + this.logger.error(`Session ${sessionId}: failed to read session info: ${error}`); + return; + } + // `customTitle` is a user-set `/rename`; `summary` is the auto-generated + // title (or first prompt). Prefer the explicit title when present. + const rawTitle = info?.customTitle ?? info?.summary; + if (!rawTitle) { + return; + } + const title = sanitizeTitle(rawTitle); + if (title === session.lastTitle) { + return; + } + session.lastTitle = title; + await this.client.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "session_info_update", + title, + updatedAt: new Date(info!.lastModified).toISOString(), + }, + }); + } + async authenticate(_params: AuthenticateRequest): Promise { if (_params.methodId === "gateway" || _params.methodId === "gateway-bedrock") { this.gatewayAuthRequest = _params as GatewayAuthRequest; @@ -1365,6 +1406,11 @@ export class ClaudeAcpAgent { if (session.cancelled) { settleActive({ stopReason: "cancelled" }); } + // The SDK generates the session title in a background task and + // persists it to the session file; `idle` is the turn-over + // signal, so it's the point at which a new title may have + // landed. Push it to the client if it changed. + await this.maybeUpdateSessionTitle(params.sessionId, session); } break; } diff --git a/src/tests/acp-agent.test.ts b/src/tests/acp-agent.test.ts index 3199d914..0db16106 100644 --- a/src/tests/acp-agent.test.ts +++ b/src/tests/acp-agent.test.ts @@ -45,6 +45,7 @@ import { import { Pushable } from "../utils.js"; import { deleteSession, + getSessionInfo, getSessionMessages, query, SDKAssistantMessage, @@ -56,6 +57,7 @@ vi.mock("@anthropic-ai/claude-agent-sdk", async (importOriginal) => { return { ...actual, deleteSession: vi.fn(), + getSessionInfo: vi.fn(), }; }); import type { @@ -1927,6 +1929,12 @@ describe("runPromptWithCancellation", () => { }); describe("stop reason propagation", () => { + // The title-update tests set `getSessionInfo` to resolve a title; reset it so + // that value can't leak into other turn-end (idle) assertions in this block. + beforeEach(() => { + vi.mocked(getSessionInfo).mockReset(); + }); + function createMockAgent() { const mockClient = { sessionUpdate: async () => {}, @@ -2322,6 +2330,98 @@ describe("stop reason propagation", () => { expect(chunkTexts).toContain("between-turn background note"); }); + it("pushes a session_info_update when the SDK generates a title at turn-end", async () => { + const sessionUpdates: any[] = []; + const mockClient = { + sessionUpdate: async (u: any) => { + sessionUpdates.push(u); + }, + } as unknown as AcpClient; + const agent = new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} }); + + vi.mocked(getSessionInfo).mockResolvedValue({ + sessionId: "test-session", + summary: "Fix the flaky title test", + lastModified: 1_700_000_000_000, + } as any); + + const input = new Pushable(); + async function* messageGenerator() { + const iter = input[Symbol.asyncIterator](); + const { value: userMessage } = await iter.next(); + yield userEcho(userMessage); + yield createResultMessage({ subtype: "success", stop_reason: "end_turn", is_error: false }); + yield { type: "system", subtype: "session_state_changed", state: "idle" }; + } + + agent.sessions["test-session"] = mockSessionState({ + query: wrapQuery(messageGenerator()), + input, + }); + + await agent.prompt({ + sessionId: "test-session", + prompt: [{ type: "text", text: "test" }], + }); + await agent.sessions["test-session"]?.consumer; + + const titleUpdate = sessionUpdates.find( + (u) => u.update?.sessionUpdate === "session_info_update", + ); + expect(titleUpdate?.update).toEqual({ + sessionUpdate: "session_info_update", + title: "Fix the flaky title test", + updatedAt: new Date(1_700_000_000_000).toISOString(), + }); + expect(getSessionInfo).toHaveBeenCalledWith("test-session", { dir: "/test" }); + }); + + it("does not re-push session_info_update when the title is unchanged", async () => { + const sessionUpdates: any[] = []; + const mockClient = { + sessionUpdate: async (u: any) => { + sessionUpdates.push(u); + }, + } as unknown as AcpClient; + const agent = new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} }); + + vi.mocked(getSessionInfo).mockResolvedValue({ + sessionId: "test-session", + summary: "Stable title", + lastModified: 1_700_000_000_000, + } as any); + + const input = new Pushable(); + async function* messageGenerator() { + const iter = input[Symbol.asyncIterator](); + // Two turns, each ending in idle, but the title never changes. + for (let i = 0; i < 2; i++) { + const { value: userMessage } = await iter.next(); + yield userEcho(userMessage); + yield createResultMessage({ + subtype: "success", + stop_reason: "end_turn", + is_error: false, + }); + yield { type: "system", subtype: "session_state_changed", state: "idle" }; + } + } + + agent.sessions["test-session"] = mockSessionState({ + query: wrapQuery(messageGenerator()), + input, + }); + + await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "one" }] }); + await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "two" }] }); + await agent.sessions["test-session"]?.consumer; + + const titleUpdates = sessionUpdates.filter( + (u) => u.update?.sessionUpdate === "session_info_update", + ); + expect(titleUpdates).toHaveLength(1); + }); + it("should throw internal error for success with is_error true and no max_tokens", async () => { const agent = createMockAgent(); injectSession(agent, [