feat: Push session title updates at turn end (#812)

This commit is contained in:
Ben Brandt
2026-06-25 13:45:50 +02:00
committed by GitHub
parent 228f02ecfb
commit 1fe7ec09a3
2 changed files with 146 additions and 0 deletions

View File

@@ -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<void> {
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<void> {
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;
}

View File

@@ -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<any>();
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<any>();
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, [