mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 04:30:59 +00:00
feat: Push session title updates at turn end (#812)
This commit is contained in:
@@ -50,6 +50,7 @@ import {
|
|||||||
AgentInfo,
|
AgentInfo,
|
||||||
CanUseTool,
|
CanUseTool,
|
||||||
deleteSession,
|
deleteSession,
|
||||||
|
getSessionInfo,
|
||||||
getSessionMessages,
|
getSessionMessages,
|
||||||
listSessions,
|
listSessions,
|
||||||
McpServerConfig,
|
McpServerConfig,
|
||||||
@@ -262,6 +263,12 @@ type Session = {
|
|||||||
/** Accumulated task list for the session, keyed by task ID. Task IDs are
|
/** Accumulated task list for the session, keyed by task ID. Task IDs are
|
||||||
* per-session, so this state must not be shared across sessions. */
|
* per-session, so this state must not be shared across sessions. */
|
||||||
taskState: TaskState;
|
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
|
/** 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
|
* 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
|
* (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> {
|
async authenticate(_params: AuthenticateRequest): Promise<void> {
|
||||||
if (_params.methodId === "gateway" || _params.methodId === "gateway-bedrock") {
|
if (_params.methodId === "gateway" || _params.methodId === "gateway-bedrock") {
|
||||||
this.gatewayAuthRequest = _params as GatewayAuthRequest;
|
this.gatewayAuthRequest = _params as GatewayAuthRequest;
|
||||||
@@ -1365,6 +1406,11 @@ export class ClaudeAcpAgent {
|
|||||||
if (session.cancelled) {
|
if (session.cancelled) {
|
||||||
settleActive({ stopReason: "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;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import {
|
|||||||
import { Pushable } from "../utils.js";
|
import { Pushable } from "../utils.js";
|
||||||
import {
|
import {
|
||||||
deleteSession,
|
deleteSession,
|
||||||
|
getSessionInfo,
|
||||||
getSessionMessages,
|
getSessionMessages,
|
||||||
query,
|
query,
|
||||||
SDKAssistantMessage,
|
SDKAssistantMessage,
|
||||||
@@ -56,6 +57,7 @@ vi.mock("@anthropic-ai/claude-agent-sdk", async (importOriginal) => {
|
|||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
deleteSession: vi.fn(),
|
deleteSession: vi.fn(),
|
||||||
|
getSessionInfo: vi.fn(),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
import type {
|
import type {
|
||||||
@@ -1927,6 +1929,12 @@ describe("runPromptWithCancellation", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("stop reason propagation", () => {
|
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() {
|
function createMockAgent() {
|
||||||
const mockClient = {
|
const mockClient = {
|
||||||
sessionUpdate: async () => {},
|
sessionUpdate: async () => {},
|
||||||
@@ -2322,6 +2330,98 @@ describe("stop reason propagation", () => {
|
|||||||
expect(chunkTexts).toContain("between-turn background note");
|
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 () => {
|
it("should throw internal error for success with is_error true and no max_tokens", async () => {
|
||||||
const agent = createMockAgent();
|
const agent = createMockAgent();
|
||||||
injectSession(agent, [
|
injectSession(agent, [
|
||||||
|
|||||||
Reference in New Issue
Block a user