mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-14 21:31:52 +00:00
Implements the [`session/set_title` RFD](https://agentclientprotocol.com/rfds/session-set-title) on ACP v1 and v2. ## What clients get - v1 advertises `agentCapabilities.sessionCapabilities.title: {}` and v2 advertises `capabilities.session.title: {}`. Both serve `session/set_title` for every client. - The session does not need to be loaded. A session that runs on this connection is renamed through its CLI (`rename_session`). Any other session gets the `custom-title` and `agent-name` records in every copy of its transcript, plus the `custom-title.json` sidecar, as the CLI's `/rename` writes them. - The title is normalized the way `_session/rename` normalizes it: whitespace collapsed, cut to the CLI's 200 characters. The response carries the stored title, as that client's `session/list` shows it. On v1 an AIR client gets it without the `[archived] ` prefix. An archived session stays archived. - A loaded session publishes `session_info_update { title }`. A title generation in flight finishes first, and no generated title replaces the client's. - Errors: a title that is empty after normalization returns `-32602`. A session that is not loaded and has no transcript returns `-32002`. On v2, a subagent session is refused, as close and delete refuse it. ## Structure - The transcript writer behind `_session/rename` lived in `src/v1/session-index/`, which v2 cannot import. Everything there except `connection.ts` moves to the shared `src/session-index/` with no change beyond import paths. `connection.ts` becomes `src/v1/session-index.ts`. - `src/session-index/rename.ts` holds the rename logic that used to be in `SessionIndexConnection.rename`/`retitle`. `_session/rename` and `session/set_title` on both versions call it. - Clients without `sessionIndex` (and every v2 client) use a `SessionIndexService` with no list and no watcher (`transcriptWriter`). Their `session/delete` now runs inside that service's per-session `exclusive()`, so a delete cannot race a rename that is still appending records. - If a restart or resume starts a new query of a v2 session while a rename runs, v2 renames through the new query too. Its CLI could otherwise hold the old title and write it back. This doesn't use the SDK's top-level `renameSession()`. It writes only a `custom-title` record to the first copy of the transcript it finds, with no `agent-name` and no sidecar, so a session renamed earlier through `/rename` would keep its old name in the CLI and in AIR. ## Tests - v1 (`session-index.test.ts`): a stored session for a client without the session index; an archived session for three clients (AIR with `sessionIndex`, AIR without it, a client that is not AIR); unknown session and empty title. - v2 (`acp-v2.test.ts`): an active session through its CLI with the published update; a stored session's records; the rename that a resume overtakes; unknown session and empty title. - Golden files: only the `initialize` capability line changes. `compare.ts` now allows the `title` capability against the origin/main baseline. - Not run: `npm run test:integration` against the real CLI. The delete/rename ordering has no test, since it is a timing race.
76 lines
2.7 KiB
TypeScript
76 lines
2.7 KiB
TypeScript
/**
|
|
* The `<task-notification>` inputs that Claude Code writes to a transcript
|
|
* when a background task stops. The replay of a session (`v1/acp-agent.ts`) and
|
|
* the session index (`session-index/background-tasks.ts`) read them here, so
|
|
* both read the same fields.
|
|
*/
|
|
|
|
export const TASK_NOTIFICATION_TAG = "task-notification";
|
|
|
|
/** The text between the first `<tag>` and the next `</tag>` after it. */
|
|
export function markerTagText(text: string, tag: string): string | undefined {
|
|
const open = `<${tag}>`;
|
|
const close = `</${tag}>`;
|
|
const start = text.indexOf(open);
|
|
if (start === -1) return undefined;
|
|
const end = text.indexOf(close, start + open.length);
|
|
if (end === -1) return undefined;
|
|
return text.slice(start + open.length, end);
|
|
}
|
|
|
|
/** The fields of a persisted `<task-notification>`, in the shape of the SDK
|
|
* `task_notification` frame that reported the same stop live. */
|
|
export type PersistedTaskNotification = {
|
|
task_id: string;
|
|
status: string;
|
|
tool_use_id?: string;
|
|
summary?: string;
|
|
output_file?: string;
|
|
};
|
|
|
|
/**
|
|
* Returns each `<task-notification>` in user-message content (a string or its
|
|
* text blocks). The `<result>` body is model context that the live frame does
|
|
* not carry, so the scan stops at it and a tag quoted in it cannot match.
|
|
*
|
|
* Only a notification with a `<task-id>` and a `<status>` is returned. A
|
|
* notification without a status comes while its task still runs: the
|
|
* check-in of a quiet shell ("is still running after …") or a `Monitor`
|
|
* event.
|
|
*/
|
|
export function taskNotificationsOf(content: unknown): PersistedTaskNotification[] {
|
|
const texts =
|
|
typeof content === "string"
|
|
? [content]
|
|
: Array.isArray(content)
|
|
? content.flatMap((block) =>
|
|
block?.type === "text" && typeof block.text === "string" ? [block.text as string] : [],
|
|
)
|
|
: [];
|
|
const open = `<${TASK_NOTIFICATION_TAG}>`;
|
|
const close = `</${TASK_NOTIFICATION_TAG}>`;
|
|
const notifications: PersistedTaskNotification[] = [];
|
|
for (const text of texts) {
|
|
let start = text.indexOf(open);
|
|
while (start !== -1) {
|
|
const end = text.indexOf(close, start + open.length);
|
|
if (end === -1) break;
|
|
const body = text.slice(start + open.length, end).split("<result>", 1)[0];
|
|
const field = (tag: string) => markerTagText(body, tag)?.trim() || undefined;
|
|
const taskId = field("task-id");
|
|
const status = field("status");
|
|
if (taskId && status) {
|
|
notifications.push({
|
|
task_id: taskId,
|
|
status,
|
|
tool_use_id: field("tool-use-id"),
|
|
summary: field("summary"),
|
|
output_file: field("output-file"),
|
|
});
|
|
}
|
|
start = text.indexOf(open, end + close.length);
|
|
}
|
|
}
|
|
return notifications;
|
|
}
|