Files
claude-agent-acp/src/task-notifications.ts
Ben Brandt a4a8534c23 feat: serve session/set_title on ACP v1 and v2 (#1585)
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.
2026-11-10 18:28:46 +00:00

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;
}