mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-10 11:21:06 +00:00
## Summary A Claude Code session can start a background shell command or a background sub-agent, and then end its turn. When a task ends, Claude Code wakes the session for a short turn. The session index reported `idle` at each turn end, so a client saw one completion for each task. Now the index finds the open background tasks in the transcript. A turn end with an open task stays `running`, also after 10 minutes. The session gets one `idle`, when the last task ends. The rules are the same as in the native Claude provider of AIR (IJAI-3744). ## Rules - A task opens with a tool result that has `backgroundTaskId`, or `isAsync` with an `agentId`. - A task closes with its `<task-notification>` (a user record or a queue `remove`), or with a `TaskStop` result. - The wait ends when no live process holds the session, or when the registry status is `idle`. - A task can start before the tail window. A later scan applies the tail on top of the earlier task set. When it cannot, the index reads the whole transcript, but only for a finished turn of a live session. ## Not in this PR - A background task that a sub-agent starts. Its start record is only in the transcript of the sub-agent. - A session that this connection runs. Its state comes from the SDK. ## Tests - `background-tasks.test.ts`: the record orders of the native provider tests, in a small transcript and in one longer than the tail window. The tests also cover a task before the tail window, the end of the wait, and the count of whole-file reads. - A real check with Claude Code 2.1.306 in a terminal. With two background shells or two background sub-agents, `main` gave three `idle` and this change gave one. --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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 (`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;
|
|
}
|