mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 04:30:59 +00:00
fix: publish the session title as soon as the CLI has it (#1406)
## Summary Session titles showed up only after the first turn ended. With background subagents, that could be minutes or hours later. Now the adapter asks for the title when the prompt is sent to the CLI. In a live run the title arrived **0.8s** after the prompt, compared with **17.9s** (at turn end) on `main`. ## Why - **The CLI already titles SDK sessions.** The bundled CLI (2.1.303) titles a new session from its first prompt within seconds and stores it as an `ai-title` transcript entry. `getSessionInfo` returns it as `customTitle`. The header comment in `session-titles.ts` said the CLI never titles SDK sessions; that is out of date. The CLI's off switch only applies to the `sdk-cli` entrypoint without `CLAUDE_AGENT_SDK_VERSION`, and the TS SDK sets `sdk-ts` and that variable. - **No message carries an AI title.** The CLI's internal `system/session_title_changed` covers only `/rename`-style names, and its schema says so. - **The adapter only checked at `idle`.** `SessionTitles.onTurnEnd` ran at `session_state_changed: idle`, which comes after the whole turn. Since CLI 2.1.270 it also waits for background subagents to finish. In the common case, `onTurnEnd` just picked up the title that had been on disk for the whole turn. ## Change - **New `SessionTitles.onPromptQueued`**, called right after the prompt is pushed to the SDK. Until the session has a title, it: 1. reads the session file, and publishes a stored title if there is one (a resumed session, or a `/rename`); 2. otherwise sends `generate_session_title` with the prompt text only, in the background, and publishes the result. - **No second model call.** The CLI runs this request alongside the turn instead of queuing it behind it. Its handler returns the stored custom title, then the AI title, and otherwise waits for the CLI's own title call already in flight. - **One try per prompt, like the CLI.** After a prompt-time request, the turn end does not ask again. If nothing came back, it publishes the stored summary as before, and the next prompt tries again. - **Unchanged:** - the turn end still titles a prompt too short to title from (under 10 characters, like "hi") from the prompt plus the reply; - the turn end still picks up a later `/rename`; - a title the client set still wins. ## Verification - `npm run check` and `npm run test:run` pass (3275 passed, 30 skipped). - New tests: - the title is published while the first turn is still running; - the raw prompt is not published at turn end while a title is still pending; - a short "hi" prompt is titled at turn end from the prompt and the reply. - The latch-release test now pins one request per prompt. Removing the new call site makes two of the tests fail. - Live check: one real text-only turn against this branch and against `main`, both on the bundled CLI 2.1.303, timing `session_info_update` against the prompt response: | build | title published | prompt response | | --- | --- | --- | | this branch | 0.8s | 17.1s | | `main` | 17.9s | 17.9s | Both runs got the same title, and both transcripts contain the same two `ai-title` entries. So the change adds no title generation; the CLI itself writes the entry twice. ## Risks - **Undocumented CLI behaviour.** This depends on the CLI answering `generate_session_title` mid-turn and returning the title it is already generating, which `sdk.d.ts` does not document. If a future CLI changes that, the worst case is one extra small-model call per session. The latch still limits it to one success. - **Titles now arrive mid-turn.** Clients that expected `session_info_update` only between turns should be checked. - **Steering is not covered.** Messages injected with `_session/steering` don't trigger a title request.
This commit is contained in:
@@ -3950,6 +3950,7 @@ export class ClaudeAcpAgent {
|
||||
(session.sentUserUuids ??= new Set()).add(promptUuid);
|
||||
session.input.push(userMessage);
|
||||
this.ensureConsumer(session, params.sessionId);
|
||||
session.titles.onPromptQueued(session, params.prompt);
|
||||
// The prompt is queued, so its turn goes ahead even if the client misses
|
||||
// the goal it sets; the turn's events report the outcome.
|
||||
await this.publishGoalFromPrompt(params.sessionId, firstText, promptUuid).catch((error) =>
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
/**
|
||||
* AI-generated session titles.
|
||||
*
|
||||
* Claude Code's own auto-title generation never runs under the Agent SDK — the
|
||||
* latch that arms it is pre-set in the headless path — so `SDKSessionInfo.summary`
|
||||
* degrades to the raw first prompt. This module asks the CLI for a real title
|
||||
* instead, via the `generate_session_title` control request, and publishes it
|
||||
* over ACP as a `session_info_update`.
|
||||
* Claude Code titles a new SDK session itself: when it reads the first prompt,
|
||||
* it asks a small model for a title and stores it in the transcript as an
|
||||
* `ai-title` entry, a few seconds in. It sends no message when it does. This
|
||||
* module asks for the title with the `generate_session_title` control request,
|
||||
* which returns the stored title or waits for the one being generated, and
|
||||
* publishes it over ACP as a `session_info_update`. Where the CLI does not
|
||||
* title the session itself (a resumed session, or one with
|
||||
* `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC` or
|
||||
* `CLAUDE_CODE_DISABLE_TERMINAL_TITLE` set), the same request generates it.
|
||||
*
|
||||
* {@link SessionTitles} holds the per-session state and is owned by `Session`.
|
||||
* `acp-agent.ts` drives it from four places: {@link SessionTitles.onPrompt} and
|
||||
* `acp-agent.ts` drives it from five places: {@link SessionTitles.onPrompt} and
|
||||
* {@link SessionTitles.onAssistantText} to collect text,
|
||||
* {@link SessionTitles.onPromptQueued} once a prompt is sent to the CLI,
|
||||
* {@link SessionTitles.onTurnEnd} at `session_state_changed: idle`, and
|
||||
* {@link SessionTitles.reset} on `conversation_reset`.
|
||||
*/
|
||||
@@ -77,6 +82,11 @@ export function appendTitleContext(previous: string | undefined, text: string):
|
||||
return next.length > MAX_TITLE_CONTEXT_LENGTH ? next.slice(-MAX_TITLE_CONTEXT_LENGTH) : next;
|
||||
}
|
||||
|
||||
/** The text chunks of a prompt, one per line. */
|
||||
function promptText(prompt: PromptRequest["prompt"]): string {
|
||||
return prompt.flatMap((chunk) => (chunk.type === "text" ? [chunk.text] : [])).join("\n");
|
||||
}
|
||||
|
||||
/** Whether this SDK still offers on-demand title generation. */
|
||||
function supportsTitleGeneration(query: Query): boolean {
|
||||
return typeof (query as TitleCapableQuery).generateSessionTitle === "function";
|
||||
@@ -100,6 +110,11 @@ export class SessionTitles {
|
||||
* one. Released by {@link reset}, and when generation yields nothing. */
|
||||
private settled = false;
|
||||
|
||||
/** Whether {@link onPromptQueued} asked for a title from the latest prompt.
|
||||
* A turn end then does not ask again: like the CLI, a prompt that yields
|
||||
* no title gets one try, and the next prompt tries again. */
|
||||
private askedAtPrompt = false;
|
||||
|
||||
/** The title a client set, which no generated title replaces. */
|
||||
private readonly explicit = new ExplicitTitle();
|
||||
|
||||
@@ -132,11 +147,33 @@ export class SessionTitles {
|
||||
if (this.settled) {
|
||||
return;
|
||||
}
|
||||
const promptText = prompt
|
||||
.flatMap((chunk) => (chunk.type === "text" ? [chunk.text] : []))
|
||||
.join("\n");
|
||||
this.context = appendTitleContext(this.context, `\n${promptText(prompt)}\n`);
|
||||
}
|
||||
|
||||
this.context = appendTitleContext(this.context, `\n${promptText}\n`);
|
||||
/** Ask for the title as soon as a prompt is sent to the CLI, instead of
|
||||
* waiting for `idle`: `idle` comes only after the whole turn and its
|
||||
* background tasks, often minutes after the CLI stored its title. The CLI
|
||||
* answers `generate_session_title` while the turn runs, and with the title
|
||||
* it stored or is generating, so this makes no second model call. The
|
||||
* prompt alone is the description, because the CLI titles from the prompt
|
||||
* alone. When this yields no title, the stored summary is published and
|
||||
* the next prompt tries again. */
|
||||
onPromptQueued(session: Session, prompt: PromptRequest["prompt"]): void {
|
||||
this.askedAtPrompt = false;
|
||||
// `session.cancelled` is not checked: after a cancelled turn it stays set
|
||||
// until the next turn starts, which is after this prompt is queued.
|
||||
if (this.settled || this.explicit.active || session.queryClosed) {
|
||||
return;
|
||||
}
|
||||
if (!supportsTitleGeneration(session.query)) {
|
||||
return;
|
||||
}
|
||||
const description = promptText(prompt).trim();
|
||||
if (description.length < MIN_TITLE_CONTEXT_LENGTH) {
|
||||
return;
|
||||
}
|
||||
this.askedAtPrompt = true;
|
||||
this.startGeneration(() => this.titleFromPrompt(session, description));
|
||||
}
|
||||
|
||||
/** Collect the assistant's own answer for the title. Chunks are appended
|
||||
@@ -152,6 +189,7 @@ export class SessionTitles {
|
||||
* `conversation_reset`, which mounts a fresh transcript. */
|
||||
reset(): void {
|
||||
this.settled = false;
|
||||
this.askedAtPrompt = false;
|
||||
this.explicit.reset();
|
||||
this.context = undefined;
|
||||
this.lastTitle = undefined;
|
||||
@@ -165,10 +203,12 @@ export class SessionTitles {
|
||||
* title — a user `/rename` or one generated earlier; the SDK folds both into
|
||||
* that one field. Adopt it and latch, so neither is ever titled over.
|
||||
*
|
||||
* With no title yet, ask the SDK to generate one in the background: it is a
|
||||
* ~2s small-model call and turn-end must not wait on it. Only when that is
|
||||
* not possible do we fall back to `summary`, which for an SDK-driven session
|
||||
* is just the raw first prompt. */
|
||||
* With no title yet, and none asked for at the latest prompt (see
|
||||
* {@link onPromptQueued}), ask the SDK to generate one in the background
|
||||
* from the prompts and replies so far: it is a ~2s small-model call and
|
||||
* turn-end must not wait on it. That covers a prompt too short to title
|
||||
* from, such as "hi". Only when that is not possible do we fall back to
|
||||
* `summary`, which for an SDK-driven session is just the raw first prompt. */
|
||||
async onTurnEnd(session: Session): Promise<void> {
|
||||
if (this.explicit.settledByClient) {
|
||||
// No title is generated over the one the client set, but a custom title
|
||||
@@ -200,17 +240,9 @@ export class SessionTitles {
|
||||
? { title: info.summary, lastModified: info.lastModified }
|
||||
: undefined;
|
||||
|
||||
if (this.canRequest(session)) {
|
||||
this.settled = true;
|
||||
|
||||
this.explicit.track(
|
||||
this.requestGenerateTitle(session, fallback).catch((error) => {
|
||||
this.agent.logger.error(
|
||||
`Session ${this.sessionId}: session title update failed: ${error}`,
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
if (!this.askedAtPrompt && this.canRequest(session)) {
|
||||
const description = this.context?.trim() ?? "";
|
||||
this.startGeneration(() => this.requestGenerateTitle(session, description, fallback));
|
||||
return;
|
||||
}
|
||||
// Only while this session has no title of its own: `summary` degrades to the
|
||||
@@ -309,17 +341,48 @@ export class SessionTitles {
|
||||
return (this.context?.trim().length ?? 0) >= MIN_TITLE_CONTEXT_LENGTH;
|
||||
}
|
||||
|
||||
/** Generate a title from the session's own text and publish it. `persist`
|
||||
* writes it to the session file, which is what carries it into
|
||||
* `session/list` and `session/load`. A null or failed generation releases the
|
||||
* latch so a later turn retries, and publishes `fallback` (the stored
|
||||
* summary) so a backend that can't generate titles is no worse off than
|
||||
* before. */
|
||||
/** Latch the title and run `generation` in the background, so neither the
|
||||
* prompt nor turn-end waits on a model call. A client title change waits
|
||||
* for it to finish (see {@link ExplicitTitle.track}). */
|
||||
private startGeneration(generation: () => Promise<void>): void {
|
||||
this.settled = true;
|
||||
this.explicit.track(
|
||||
generation().catch((error) => {
|
||||
this.agent.logger.error(`Session ${this.sessionId}: session title update failed: ${error}`);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** The prompt-time title: the one the session file already holds (a resumed
|
||||
* session's title, or a `/rename`), else one generated from `description`.
|
||||
* The file is read first because the CLI checks only the titles it holds in
|
||||
* memory before generating one. */
|
||||
private async titleFromPrompt(session: Session, description: string): Promise<void> {
|
||||
const info = await this.readSessionInfo(session);
|
||||
if (this.agent.sessions[this.sessionId] !== session || this.explicit.active) {
|
||||
return;
|
||||
}
|
||||
if (info?.customTitle) {
|
||||
this.context = undefined;
|
||||
await this.publish(info.customTitle, info.lastModified);
|
||||
return;
|
||||
}
|
||||
const fallback = info?.summary
|
||||
? { title: info.summary, lastModified: info.lastModified }
|
||||
: undefined;
|
||||
await this.requestGenerateTitle(session, description, fallback);
|
||||
}
|
||||
|
||||
/** Generate a title from `description` and publish it. `persist` writes it
|
||||
* to the session file, which is what carries it into `session/list` and
|
||||
* `session/load`. A null or failed generation releases the latch so a later
|
||||
* prompt or turn end retries, and publishes `fallback` (the stored summary)
|
||||
* so a backend that can't generate titles is no worse off than before. */
|
||||
private async requestGenerateTitle(
|
||||
session: Session,
|
||||
description: string,
|
||||
fallback: TitleFallback | undefined,
|
||||
): Promise<void> {
|
||||
const description = this.context?.trim() ?? "";
|
||||
let title: string | null = null;
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { createRequire } from "node:module";
|
||||
import { ClaudeAcpAgent, type AcpClient } from "../acp-agent.js";
|
||||
@@ -73,7 +74,7 @@ describe("SDK title generation contract", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("session titles at turn-end", () => {
|
||||
describe("session titles", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getSessionInfo).mockReset();
|
||||
});
|
||||
@@ -179,7 +180,129 @@ describe("session titles at turn-end", () => {
|
||||
expect(titles()).toEqual(["Stable title"]);
|
||||
});
|
||||
|
||||
it("generates a title at turn-end instead of publishing the raw prompt", async () => {
|
||||
/** A stream event carrying one chunk of the assistant's answer. */
|
||||
function textDelta(text: string) {
|
||||
return {
|
||||
type: "stream_event",
|
||||
event: { type: "content_block_delta", index: 0, delta: { type: "text_delta", text } },
|
||||
parent_tool_use_id: null,
|
||||
uuid: randomUUID(),
|
||||
session_id: "test-session",
|
||||
};
|
||||
}
|
||||
|
||||
/** A promise and the function that resolves it. */
|
||||
function deferred<T = void>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((r) => (resolve = r));
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
it("publishes the generated title while the first turn is still running", async () => {
|
||||
const { client, titles } = titleRecorder();
|
||||
const agent = newAgent(client);
|
||||
|
||||
vi.mocked(getSessionInfo).mockResolvedValue({
|
||||
sessionId: "test-session",
|
||||
summary: LONG_PROMPT,
|
||||
lastModified: 1_700_000_000_000,
|
||||
} as any);
|
||||
|
||||
const input = new Pushable<any>();
|
||||
const turnMayEnd = deferred();
|
||||
async function* longTurn() {
|
||||
const { value: userMessage } = await input[Symbol.asyncIterator]().next();
|
||||
yield userEcho(userMessage);
|
||||
await turnMayEnd.promise;
|
||||
yield successfulResultMessage();
|
||||
yield { type: "system", subtype: "session_state_changed", state: "idle" };
|
||||
}
|
||||
const { query, generateSessionTitle } = wrapTitleQuery(longTurn(), "Explain add() in hello.py");
|
||||
agent.sessions["test-session"] = mockSessionState({ query, input }, agent);
|
||||
|
||||
let turnEnded = false;
|
||||
const turn = agent
|
||||
.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: LONG_PROMPT }] })
|
||||
.then(() => (turnEnded = true));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(titles()).toEqual(["Explain add() in hello.py"]);
|
||||
});
|
||||
expect(turnEnded).toBe(false);
|
||||
expect(generateSessionTitle).toHaveBeenCalledWith(LONG_PROMPT, { persist: true });
|
||||
|
||||
turnMayEnd.resolve();
|
||||
await turn;
|
||||
await agent.sessions["test-session"]?.consumer;
|
||||
|
||||
expect(generateSessionTitle).toHaveBeenCalledTimes(1);
|
||||
expect(titles()).toEqual(["Explain add() in hello.py"]);
|
||||
});
|
||||
|
||||
it("does not publish the raw prompt at turn end while the title is being generated", async () => {
|
||||
const { client, titles } = titleRecorder();
|
||||
const agent = newAgent(client);
|
||||
|
||||
vi.mocked(getSessionInfo).mockResolvedValue({
|
||||
sessionId: "test-session",
|
||||
summary: LONG_PROMPT,
|
||||
lastModified: 1_700_000_000_000,
|
||||
} as any);
|
||||
|
||||
const input = new Pushable<any>();
|
||||
const generated = deferred<string | null>();
|
||||
const generateSessionTitle = vi.fn(() => generated.promise);
|
||||
const query = Object.assign(wrapQuery(oneTurn(input)), { generateSessionTitle });
|
||||
agent.sessions["test-session"] = mockSessionState({ query, input }, agent);
|
||||
|
||||
await agent.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: LONG_PROMPT }],
|
||||
});
|
||||
await agent.sessions["test-session"]?.consumer;
|
||||
expect(titles()).toEqual([]);
|
||||
|
||||
generated.resolve("Explain add() in hello.py");
|
||||
await vi.waitFor(() => {
|
||||
expect(titles()).toEqual(["Explain add() in hello.py"]);
|
||||
});
|
||||
expect(generateSessionTitle).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("titles a prompt too short to title from at turn end, from the prompt and the reply", async () => {
|
||||
const { client, titles } = titleRecorder();
|
||||
const agent = newAgent(client);
|
||||
|
||||
vi.mocked(getSessionInfo).mockResolvedValue({
|
||||
sessionId: "test-session",
|
||||
summary: "hi",
|
||||
lastModified: 1_700_000_000_000,
|
||||
} as any);
|
||||
|
||||
const input = new Pushable<any>();
|
||||
async function* greetingTurn() {
|
||||
const { value: userMessage } = await input[Symbol.asyncIterator]().next();
|
||||
yield userEcho(userMessage);
|
||||
yield textDelta("Hello! What are we working on today?");
|
||||
yield successfulResultMessage();
|
||||
yield { type: "system", subtype: "session_state_changed", state: "idle" };
|
||||
}
|
||||
const { query, generateSessionTitle } = wrapTitleQuery(greetingTurn(), "Greeting");
|
||||
agent.sessions["test-session"] = mockSessionState({ query, input }, agent);
|
||||
|
||||
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "hi" }] });
|
||||
await agent.sessions["test-session"]?.consumer;
|
||||
await vi.waitFor(() => {
|
||||
expect(titles()).toEqual(["Greeting"]);
|
||||
});
|
||||
|
||||
expect(generateSessionTitle).toHaveBeenCalledTimes(1);
|
||||
expect(generateSessionTitle).toHaveBeenCalledWith("hi\nHello! What are we working on today?", {
|
||||
persist: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("generates a title from the prompt instead of publishing the raw prompt", async () => {
|
||||
const { client, titles } = titleRecorder();
|
||||
const agent = newAgent(client);
|
||||
|
||||
@@ -203,7 +326,6 @@ describe("session titles at turn-end", () => {
|
||||
prompt: [{ type: "text", text: LONG_PROMPT }],
|
||||
});
|
||||
await agent.sessions["test-session"]?.consumer;
|
||||
// Generation is fired off the turn-end path, so it lands after `idle`.
|
||||
await vi.waitFor(() => {
|
||||
expect(generateSessionTitle).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -302,9 +424,13 @@ describe("session titles at turn-end", () => {
|
||||
prompt: [{ type: "text", text: "And what is the capital of France?" }],
|
||||
});
|
||||
await agent.sessions["test-session"]?.consumer;
|
||||
// One try per prompt: a turn end does not ask again after its prompt did.
|
||||
await vi.waitFor(() => {
|
||||
expect(generateSessionTitle).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
expect(generateSessionTitle).toHaveBeenLastCalledWith("And what is the capital of France?", {
|
||||
persist: true,
|
||||
});
|
||||
|
||||
// Nothing generated, so the client still gets the summary fallback.
|
||||
expect(titles()).toEqual([LONG_PROMPT]);
|
||||
|
||||
Reference in New Issue
Block a user