mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-12 20:31:15 +00:00
Compare commits
4 Commits
v0.106.1-p
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4804917a6f | ||
|
|
c331e178a5 | ||
|
|
830f05a0ac | ||
|
|
4df69996ca |
77
README.md
77
README.md
@@ -53,81 +53,6 @@ npm run build:binary
|
||||
npm run smoke:binary # add `-- --full` to drive a real session; needs a Claude login
|
||||
```
|
||||
|
||||
### Subagent sessions
|
||||
|
||||
A client that declares `clientCapabilities.subagents`, other than JetBrains AIR, gets each Agent or
|
||||
Task subagent as its own child session, following the [subagents RFD](https://github.com/agentclientprotocol/agent-client-protocol/blob/main/docs/rfds/subagents.mdx):
|
||||
|
||||
- `subagent_update` on the parent announces the child before any of its traffic, with the
|
||||
parent's `title` and `description` for it, and reports its work state: `running`,
|
||||
`requires_action` while one of its requests is open (and `running` again when the last one ends,
|
||||
unless a cancel withdrew it: the child is stopping then), and `idle` when its work ends, with
|
||||
`end_turn` or `cancelled` when the SDK gives the reason. A failure is `idle` with the `error`
|
||||
stop reason and the SDK's error text as its `error`. The child keeps its session for every
|
||||
delegation to it.
|
||||
- A child that runs outside its parent's working directory, such as an Agent call with
|
||||
`isolation: "worktree"`, gets its `cwd` on `subagent_update` before its first tool call. The
|
||||
adapter takes it from the `cwd` of the child's own hooks: its SubagentStart hook runs before its
|
||||
first model request. It sends `cwd: null` when the child is back in its parent's directory. A
|
||||
child in its parent's directory gets no `cwd`, and no child gets `additionalDirectories`: Claude
|
||||
Code gives a subagent the session's additional directories, so it inherits them. A replay
|
||||
announces a child with the `cwd` that its transcript recorded, and clears it after the response
|
||||
when that directory no longer exists.
|
||||
- Session messages replace the Agent or Task call that launches the child, which its parent is
|
||||
not shown:
|
||||
- The launch's prompt is a `session_message` in both transcripts: the parent's outgoing view and
|
||||
the child's incoming one. Both use the launch's tool use id, and each names the other with the
|
||||
draft RFD's `recipientMessageId` and `senderMessageId`.
|
||||
- A foreground child's answer, from the launch's result, is the child's `session_message` to
|
||||
the parent. In Claude Code's hand-back mode, the child's `SubagentHandback` call is its
|
||||
message to the parent instead, in both transcripts and linked the same way.
|
||||
- A launch whose child is never exposed is shown as a tool call after all, with its final
|
||||
status.
|
||||
- The message of a SendMessage call that resumes the child is a `session_message` in the child's
|
||||
transcript. The SendMessage call stays a tool call of the session that made it.
|
||||
- `session/cancel` on the parent interrupts Claude Code, which stops the foreground children that
|
||||
the turn waits on. Background children keep running, as the RFD prefers; a client stops one with
|
||||
its own `cancel`. Each stopped child reports `idle` with `cancelled` from Claude Code's own
|
||||
events, and its open requests are withdrawn. The adapter ends no child at a cancel itself, so a
|
||||
child that keeps running keeps its open requests and its open tool calls. To spare background
|
||||
children, the adapter declares the SDK option `perTaskStopAffordance`, which a client can turn
|
||||
off in `_meta.claudeCode.options`. That option spares background workflows too, which a client
|
||||
cannot stop, so the adapter stops them after the interrupt, as Claude Code did without it.
|
||||
Background commands keep running after a cancel either way. A tool call of a stopped child that Claude Code reports no
|
||||
result for, such as the command it was running, ends in the child's session before the cancelled
|
||||
prompt resolves. When Claude Code stops a background child after that, or while no prompt runs,
|
||||
the tool call ends right after the child reports `idle`. A child that still runs when Claude Code
|
||||
exits is `idle` with no stop reason.
|
||||
- A child whose parent is not yet known when it ends is not exposed, since the RFD forbids
|
||||
guessing its parent.
|
||||
- A replay restores each child under its live session id, from its own transcript, with the same
|
||||
session messages as live, and its end is an `idle` before the response.
|
||||
A launch without Claude Code's metadata file for the child stays an ordinary tool call of the
|
||||
parent, and a notice, for a client that takes notices, says that subagent history is missing.
|
||||
- After a load or resume, with or without replay, a SendMessage that resumes an agent from before
|
||||
it runs that agent in its session from before. Before the agent's traffic, each of its ancestors
|
||||
that the client was not told about in this connection is announced, parent before child and
|
||||
with no work state, as the RFD requires. An agent whose launch the session's history does not
|
||||
record stays unexposed.
|
||||
- Each running child is announced with the `cancel` capability. `session/cancel` with the child's
|
||||
session id stops that child's current work, through the SDK's `Query.stopTask`, and not its
|
||||
parent's: Claude Code also stops the foreground children it waits on, and the parent's turn goes
|
||||
on. Each stopped child reports `idle` with `cancelled` from Claude Code's own events, and its
|
||||
requests that the stop withdraws report no `running` first. A replayed child, which no longer
|
||||
runs, gets no capabilities. A client cannot prompt or otherwise change a child.
|
||||
|
||||
An [ACP v2](#experimental-acp-v2) client gets the same without declaring anything, since subagents
|
||||
are part of v2's baseline. Each child also reports its work state as its own `state_update`, right
|
||||
after the parent's copy.
|
||||
|
||||
JetBrains AIR keeps the earlier draft of the RFD (`subagent_spawned`, `subagent_state_update`),
|
||||
which it enables with `nativeSubagentSessions` in `_meta.jetbrains.air.capabilities`. See
|
||||
[AIR extensions](docs/air-extensions.md#native-subagent-sessions).
|
||||
|
||||
Without either signal, Agent and Task stay ordinary tool calls and child interactions stay on the
|
||||
root session. Clients that use the historical `_meta["subagent-transcript"]` capability or the
|
||||
`forwardSubagentText` session option retain the flattened child transcript.
|
||||
|
||||
### Experimental ACP v2
|
||||
|
||||
The adapter also serves the [draft ACP v2](https://agentclientprotocol.com/protocol/v2/overview):
|
||||
@@ -143,7 +68,7 @@ with replay, `session/close`, `session/prompt`, `session/cancel`), `session/dele
|
||||
`session/fork`, `session/status`, `session/options` (with initial `configOptions` on `session/new` and
|
||||
`session/resume`), `session/set_title`, additional directories, config options (the mode is one), plans, notices,
|
||||
compaction, display terminals for commands, permission requests, elicitation, providers, auth, and
|
||||
[subagents](#subagent-sessions). Steering is v1 only for now.
|
||||
[subagents](docs/subagent-sessions.md). Steering is v1 only for now.
|
||||
|
||||
v2 is served by the v2 agent in `src/v2/`, which runs straight on Claude
|
||||
Code instead of translating to v1. A v2 client that declares JetBrains AIR
|
||||
|
||||
93
docs/subagent-sessions.md
Normal file
93
docs/subagent-sessions.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# Subagent sessions
|
||||
|
||||
A client that declares `clientCapabilities.subagents`, other than JetBrains AIR, gets each Agent or
|
||||
Task subagent as its own child session, following the [subagents RFD](https://github.com/agentclientprotocol/agent-client-protocol/blob/main/docs/rfds/subagents.mdx):
|
||||
|
||||
- `subagent_update` on the parent announces the child before any of its traffic, with the
|
||||
parent's `title` and `description` for it, and reports its work state: `running`,
|
||||
`requires_action` while one of its requests is open (and `running` again when the last one ends,
|
||||
unless a cancel withdrew it: the child is stopping then), and `idle` when its work ends, with
|
||||
`end_turn` or `cancelled` when the SDK gives the reason. A failure is `idle` with the `error`
|
||||
stop reason and the SDK's error text as its `error`. The child keeps its session for every
|
||||
delegation to it.
|
||||
- A child that still works when Claude Code starts a fresh conversation, such as for `/clear`, is
|
||||
`unknown`, and loses its `cancel` capability. Claude Code can keep such a child running, but the
|
||||
adapter forgets the children of the old conversation, so it reports nothing more for it and
|
||||
cannot stop it. Its unfinished tool calls keep their status. An idle child keeps its state.
|
||||
- A child that runs outside its parent's working directory, such as an Agent call with
|
||||
`isolation: "worktree"`, gets its `cwd` on `subagent_update` before its first tool call. The
|
||||
adapter takes it from the `cwd` of the child's own hooks: its SubagentStart hook runs before its
|
||||
first model request. It sends `cwd: null` when the child is back in its parent's directory. A
|
||||
child in its parent's directory gets no `cwd`, and no child gets `additionalDirectories`: Claude
|
||||
Code gives a subagent the session's additional directories, so it inherits them. A replay
|
||||
announces a child with the `cwd` that its transcript recorded, and clears it after the response
|
||||
when that directory no longer exists.
|
||||
- Session messages replace the Agent or Task call that launches the child, which its parent is
|
||||
not shown:
|
||||
- The launch's prompt is a `session_message` in both transcripts: the parent's outgoing view and
|
||||
the child's incoming one. Both use the launch's tool use id, and each names the other with the
|
||||
RFD's `recipientMessageId` and `senderMessageId`.
|
||||
- A foreground child's answer, from the launch's result, is the child's `session_message` to
|
||||
the parent. In Claude Code's hand-back mode, the child's `SubagentHandback` call is its
|
||||
message to the parent instead, in both transcripts and linked the same way.
|
||||
- A launch whose child is never exposed is shown as a tool call after all, with its final
|
||||
status.
|
||||
- A SendMessage call that resumes the child is not shown either. Its message is a
|
||||
`session_message` in both transcripts, the sender's outgoing view and the child's incoming one,
|
||||
linked like a launch's prompt. A SendMessage call whose message resumes no announced child,
|
||||
such as one to the parent, is shown as a tool call, with its final status.
|
||||
- Every message is one whole `session_message`. The adapter sends no `session_message_chunk`: it
|
||||
has each message's full text before it reports it, from the input or the result of the tool call
|
||||
that sent it.
|
||||
- A tool call that the client is shown and that operates on an announced child of another session
|
||||
carries the RFD's `session` content, `{ "type": "session", "sessionId": "<child>" }`, after its
|
||||
other content: a TaskStop call whose `task_id`, or a SendMessage call whose `to`, is the child's
|
||||
agent id; a SendMessage call that resumed the child and stays shown because a permission request
|
||||
showed it first; and, on v1, an Agent or Task call that stays shown for the same reason. A
|
||||
`tool_call_update` without content carries none, since it would replace the content the client
|
||||
holds. A call that names the child by its name gets no reference: Claude Code resolves the
|
||||
name, and the adapter cannot tell which agent it picked.
|
||||
- `session/cancel` on the parent interrupts Claude Code, which stops the foreground children that
|
||||
the turn waits on. Background children keep running, as the RFD prefers; a client stops one with
|
||||
its own `cancel`. Each stopped child reports `idle` with `cancelled` from Claude Code's own
|
||||
events, and its open requests are withdrawn. The adapter ends no child at a cancel itself, so a
|
||||
child that keeps running keeps its open requests and its open tool calls. To spare background
|
||||
children, the adapter declares the SDK option `perTaskStopAffordance`, which a client can turn
|
||||
off in `_meta.claudeCode.options`. That option spares background workflows too, which a client
|
||||
cannot stop, so the adapter stops them after the interrupt, as Claude Code did without it.
|
||||
Background commands keep running after a cancel either way. A tool call of a stopped child that Claude Code reports no
|
||||
result for, such as the command it was running, ends in the child's session before the cancelled
|
||||
prompt resolves. When Claude Code stops a background child after that, or while no prompt runs,
|
||||
the tool call ends right after the child reports `idle`. A child that still runs when Claude Code
|
||||
exits is `idle`, since its work ended with the process: with no stop reason on v1, and on v2
|
||||
with the `error` stop reason and the error that ends the root's work.
|
||||
- A child whose parent is not yet known when it ends is not exposed, since the RFD forbids
|
||||
guessing its parent.
|
||||
- A replay restores each child under its live session id, from its own transcript, with the same
|
||||
session messages and `session` references as live, and its end is an `idle` before the response.
|
||||
Each replayed child's current state follows the response.
|
||||
A launch without Claude Code's metadata file for the child stays an ordinary tool call of the
|
||||
parent, and a notice, for a client that takes notices, says that subagent history is missing.
|
||||
- After a load or resume, with or without replay, a SendMessage that resumes an agent from before
|
||||
it runs that agent in its session from before. Before the agent's traffic, each of its ancestors
|
||||
that the client was not told about in this connection is announced, parent before child and
|
||||
with no work state, as the RFD requires. An agent whose launch the session's history does not
|
||||
record stays unexposed.
|
||||
- Each running child is announced with the `cancel` capability. `session/cancel` with the child's
|
||||
session id stops that child's current work, through the SDK's `Query.stopTask`, and not its
|
||||
parent's: Claude Code also stops the foreground children it waits on, and the parent's turn goes
|
||||
on. Each stopped child reports `idle` with `cancelled` from Claude Code's own events, and its
|
||||
requests that the stop withdraws report no `running` first. A replayed child, which no longer
|
||||
runs, gets no capabilities. A client cannot prompt or otherwise change a child.
|
||||
|
||||
An [ACP v2](../README.md#experimental-acp-v2) client gets the same without declaring anything, since subagents
|
||||
are part of v2's baseline. Each child also reports its work state as its own `state_update`, right
|
||||
after the parent's copy.
|
||||
|
||||
JetBrains AIR keeps the earlier draft of the RFD (`subagent_spawned`, `subagent_state_update`),
|
||||
which it enables with `nativeSubagentSessions` in `_meta.jetbrains.air.capabilities`. See
|
||||
[AIR extensions](air-extensions.md#native-subagent-sessions).
|
||||
|
||||
Without either signal, Agent and Task stay ordinary tool calls and child interactions stay on the
|
||||
root session. Clients that use the historical `_meta["subagent-transcript"]` capability or the
|
||||
`forwardSubagentText` session option retain the flattened child transcript.
|
||||
@@ -117,10 +117,15 @@ export type TaskEnd = {
|
||||
/** The SDK's reason for an unusual end, such as `worker_restart`. */
|
||||
reason?: string;
|
||||
/**
|
||||
* The adapter knows that the work ended, but not why: the stream ended, or
|
||||
* the conversation was reset, with the task still running.
|
||||
* The adapter knows that the work ended, but not why: the stream ended
|
||||
* with the task still running.
|
||||
*/
|
||||
unexplained?: boolean;
|
||||
/**
|
||||
* The adapter stops following the task, which may still run: the
|
||||
* conversation was reset, and the runtime forgets its children.
|
||||
*/
|
||||
unobservable?: boolean;
|
||||
};
|
||||
|
||||
type TaskStarted = {
|
||||
@@ -225,6 +230,11 @@ export class NativeSubagentRuntime {
|
||||
string,
|
||||
{ initial: AcpSessionNotification; message?: string; delegationId?: string }
|
||||
>();
|
||||
/**
|
||||
* The RFD form: the agent that each TaskStop or SendMessage call names (see
|
||||
* {@link targetAgentId}), until the call's final update.
|
||||
*/
|
||||
private readonly targetByToolCall = new Map<string, string>();
|
||||
private readonly generationByTaskId = new Map<string, number>();
|
||||
private readonly pending = new Map<string, AcpSessionNotification[]>();
|
||||
private pendingCount = 0;
|
||||
@@ -280,7 +290,10 @@ export class NativeSubagentRuntime {
|
||||
const routed = await this.routeUpdate(notification, deliver, forcedSessionId);
|
||||
if (!routed) return routed;
|
||||
this.rememberSendMessage(routed);
|
||||
return this.form === "rfd" ? this.routeRfdSendMessage(routed, forcedSessionId) : routed;
|
||||
if (this.form !== "rfd") return routed;
|
||||
this.rememberTarget(routed);
|
||||
const shown = this.routeRfdSendMessage(routed, forcedSessionId);
|
||||
return shown && this.withTarget(shown);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -828,7 +841,10 @@ export class NativeSubagentRuntime {
|
||||
if (child.parentToolUseId) {
|
||||
for (const pending of this.takePending(child.parentToolUseId)) await deliver(pending);
|
||||
}
|
||||
if (this.form === "rfd") {
|
||||
if (this.form === "rfd" && end?.unobservable) {
|
||||
// The child's `cancel` goes with it: a forgotten child cannot be stopped.
|
||||
await this.reportState(child, { state: "unknown" }, { capabilities: null });
|
||||
} else if (this.form === "rfd") {
|
||||
await this.reportState(child, rfdEndState(state, end));
|
||||
child.missingError = state === "failed" && !end?.error && !end?.unexplained;
|
||||
if (child.parentToolUseId) (child.endedRuns ??= new Set()).add(child.parentToolUseId);
|
||||
@@ -853,14 +869,33 @@ export class NativeSubagentRuntime {
|
||||
|
||||
/**
|
||||
* Ends every child that still runs, because the stream ended or the
|
||||
* conversation was reset. AIR's draft reports `state` for each. In the RFD
|
||||
* form the cause of each child's end is unknown, so it is idle with no stop
|
||||
* reason: a child that a cancel stopped already reported it from the SDK's
|
||||
* events, and a background child that outlived the cancel was not cancelled.
|
||||
* conversation was reset. AIR's draft reports `state` for each.
|
||||
*
|
||||
* The RFD form, at the end of the stream: Claude Code's process ran the
|
||||
* children and is gone, or the caller closes the query next, which
|
||||
* terminates it, so their work ended. Its cause is unknown, so each is idle with no stop reason: a
|
||||
* child that a cancel stopped already reported it from the SDK's events,
|
||||
* and a background child that outlived the cancel was not cancelled.
|
||||
*
|
||||
* The RFD form, at a conversation reset (`mayStillRun`): Claude Code can
|
||||
* keep the old conversation's subagents running, and the caller forgets
|
||||
* them next (`clear`), so no later event of theirs is reported. Their work
|
||||
* did not end as far as the adapter knows, so each is `unknown`, as the
|
||||
* RFD requires when a child can no longer be observed, and loses its
|
||||
* `cancel` capability. A child that is idle keeps its state.
|
||||
*/
|
||||
async finishAll(state: SubagentState, deliver: Publish): Promise<void> {
|
||||
async finishAll(
|
||||
state: SubagentState,
|
||||
deliver: Publish,
|
||||
{ mayStillRun = false }: { mayStillRun?: boolean } = {},
|
||||
): Promise<void> {
|
||||
const errors: unknown[] = [];
|
||||
const end = this.form === "rfd" ? { unexplained: true } : undefined;
|
||||
const end: TaskEnd | undefined =
|
||||
this.form !== "rfd"
|
||||
? undefined
|
||||
: mayStillRun
|
||||
? { unobservable: true }
|
||||
: { unexplained: true };
|
||||
try {
|
||||
for (const taskId of [...this.children.keys()].reverse()) {
|
||||
try {
|
||||
@@ -1235,13 +1270,25 @@ export class NativeSubagentRuntime {
|
||||
await this.publishConfiguration(child);
|
||||
}
|
||||
|
||||
/** The RFD form: publishes the work state of `child` on its parent. */
|
||||
private async reportState(child: NativeSubagent, state: WorkState): Promise<void> {
|
||||
/**
|
||||
* The RFD form: publishes the work state of `child` on its parent, with
|
||||
* `capabilities` when the child's change with it.
|
||||
*/
|
||||
private async reportState(
|
||||
child: NativeSubagent,
|
||||
state: WorkState,
|
||||
capabilities?: { capabilities: null },
|
||||
): Promise<void> {
|
||||
child.workState = state.state;
|
||||
child.missingError = false;
|
||||
await this.publish({
|
||||
sessionId: child.parentSessionId,
|
||||
update: { sessionUpdate: "subagent_update", sessionId: child.sessionId, state },
|
||||
update: {
|
||||
sessionUpdate: "subagent_update",
|
||||
sessionId: child.sessionId,
|
||||
...capabilities,
|
||||
state,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1439,6 +1486,42 @@ export class NativeSubagentRuntime {
|
||||
return unexposedControlFallback(sendMessage.initial, notification, sendMessage.sender);
|
||||
}
|
||||
|
||||
/** The RFD form: remembers the agent that a TaskStop or SendMessage call names. */
|
||||
private rememberTarget({ update }: AcpSessionNotification): void {
|
||||
if (update.sessionUpdate !== "tool_call" && update.sessionUpdate !== "tool_call_update") return;
|
||||
const claudeMeta = update._meta?.claudeCode as { toolName?: unknown } | undefined;
|
||||
const agentId = targetAgentId(claudeMeta?.toolName, update.rawInput);
|
||||
if (agentId !== undefined) this.targetByToolCall.set(update.toolCallId, agentId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The RFD form: `notification`, an update that the client is shown, with
|
||||
* the RFD's `session` reference when it is of a tool call that operates on
|
||||
* an announced child of another session (see {@link withSessionReference}):
|
||||
*
|
||||
* - a TaskStop or SendMessage call that names the child by its agent id;
|
||||
* - a SendMessage call that resumed the child, and stays shown because a
|
||||
* permission request showed it;
|
||||
* - an Agent/Task call that launched the child, and stays shown for the
|
||||
* same reason.
|
||||
*
|
||||
* The RFD requires the announcement before the reference, so a call whose
|
||||
* child is not announced yet gets none.
|
||||
*/
|
||||
private withTarget(notification: AcpSessionNotification): AcpSessionNotification {
|
||||
const { update } = notification;
|
||||
if (update.sessionUpdate !== "tool_call" && update.sessionUpdate !== "tool_call_update") {
|
||||
return notification;
|
||||
}
|
||||
const agentId = this.targetByToolCall.get(update.toolCallId);
|
||||
if (isFinalToolCallStatus(update.status)) this.targetByToolCall.delete(update.toolCallId);
|
||||
const child =
|
||||
this.childByParentToolUse.get(update.toolCallId) ??
|
||||
(agentId !== undefined ? this.children.get(agentId) : undefined);
|
||||
if (!child?.announced || child.sessionId === notification.sessionId) return notification;
|
||||
return withSessionReference(notification, child.sessionId);
|
||||
}
|
||||
|
||||
/** Whether `update` is of an Agent/Task call, by its tool name or, in the
|
||||
* RFD form, by the id of a call the client is not shown. */
|
||||
private isControlUpdate(
|
||||
@@ -1504,6 +1587,7 @@ export class NativeSubagentRuntime {
|
||||
this.hiddenLaunches.clear();
|
||||
this.hiddenSendMessages.clear();
|
||||
this.handbacks.clear();
|
||||
this.targetByToolCall.clear();
|
||||
this.generationByTaskId.clear();
|
||||
this.pending.clear();
|
||||
this.pendingCount = 0;
|
||||
@@ -1835,7 +1919,7 @@ async function publishPrompt(
|
||||
* prompt goes from the parent to the child, a SendMessage call, whose message
|
||||
* goes from its sender to a child, or a child's SubagentHandback call, whose
|
||||
* report goes from the child to the parent. Both views use the
|
||||
* call's tool use id, and each names the other with the draft RFD's
|
||||
* call's tool use id, and each names the other with the RFD's
|
||||
* `recipientMessageId` and `senderMessageId`.
|
||||
*/
|
||||
export function linkedSessionMessages(
|
||||
@@ -1863,6 +1947,49 @@ export function linkedSessionMessages(
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The RFD form: the agent that a tool call names as the one it operates on:
|
||||
* the `task_id` of a TaskStop call, or the `to` of a SendMessage call. It is
|
||||
* an agent only if it is the id of one: an agent's id is its child session's
|
||||
* id. Both tools also take a name, which is not resolved here: Claude Code
|
||||
* resolves it, and the adapter cannot tell which agent it picked. `undefined`
|
||||
* for any other call.
|
||||
*/
|
||||
export function targetAgentId(toolName: unknown, rawInput: unknown): string | undefined {
|
||||
if (typeof rawInput !== "object" || rawInput === null) return undefined;
|
||||
const input = rawInput as { task_id?: unknown; to?: unknown };
|
||||
if (toolName === "TaskStop") return nonBlankString(input.task_id);
|
||||
if (toolName === "SendMessage") return nonBlankString(input.to);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The RFD form: `notification`, a tool call's report, with a `session`
|
||||
* reference to `sessionId` after its content: `{ "type": "session",
|
||||
* "sessionId": "..." }`, which links the call to the session it operates on.
|
||||
* A `tool_call_update` replaces the content that the client holds, so the
|
||||
* reference goes on every report that carries content, and a
|
||||
* `tool_call_update` without content is returned as it is.
|
||||
*/
|
||||
export function withSessionReference(
|
||||
notification: AcpSessionNotification,
|
||||
sessionId: string,
|
||||
): AcpSessionNotification {
|
||||
const { update } = notification;
|
||||
if (update.sessionUpdate !== "tool_call" && update.sessionUpdate !== "tool_call_update") {
|
||||
return notification;
|
||||
}
|
||||
if (update.sessionUpdate === "tool_call_update" && update.content == null) return notification;
|
||||
const content = update.content ?? [];
|
||||
if (content.some((item) => item.type === "session" && item.sessionId === sessionId)) {
|
||||
return notification;
|
||||
}
|
||||
return {
|
||||
...notification,
|
||||
update: { ...update, content: [...content, { type: "session", sessionId }] },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The tool with which a child hands its report to its parent, when Claude
|
||||
* Code runs in hand-back mode. The Agent/Task result then only says that the
|
||||
@@ -1953,6 +2080,9 @@ function isWorking(child: NativeSubagent): boolean {
|
||||
* A task that a worker restart orphaned, or that was still running when the
|
||||
* stream ended, is idle with no stop reason: its work ended, but the adapter
|
||||
* did not see a cancellation or a failure, which the RFD forbids claiming.
|
||||
*
|
||||
* A child that the adapter can no longer observe and that may still run is
|
||||
* not idle but `unknown` (see `NativeSubagentRuntime.finishAll`).
|
||||
*/
|
||||
export function rfdEndState(state: SubagentState, end: TaskEnd | undefined): WorkState {
|
||||
if (end?.unexplained || end?.reason === "worker_restart") return { state: "idle" };
|
||||
|
||||
@@ -28,16 +28,9 @@ export function fastModeStateEnabled(state: FastModeState): boolean {
|
||||
return state !== "off";
|
||||
}
|
||||
|
||||
/** User-facing explanations for the SDK's `fast_mode_disabled_reason` values
|
||||
* that a user can act on (or at least wants to know about). Deliberately
|
||||
* partial — the omitted reasons are not worth surfacing:
|
||||
* - `sdk_opt_in_required`: every SDK session starts here (the toggle IS the
|
||||
* opt-in), so it describes the default, not a problem.
|
||||
* - `preference`: the user turned Fast mode off themselves.
|
||||
* - `pending`: eligibility is still resolving; the next report supersedes it.
|
||||
* - `unknown`: nothing meaningful to say.
|
||||
* Unknown future reasons fall through the same way (open set — the SDK's docs
|
||||
* say to ignore values you don't handle). */
|
||||
/** User-facing explanations for the SDK's `fast_mode_disabled_reason` values.
|
||||
* Every reason is explained except the two in {@link FAST_MODE_UNEXPLAINED_REASONS};
|
||||
* a code missing here gets the generic text from {@link explainFastModeDisabledReason}. */
|
||||
const FAST_MODE_UNAVAILABLE_EXPLANATIONS: Partial<Record<FastModeDisabledReason, string>> = {
|
||||
free: "not available on the free plan",
|
||||
extra_usage_disabled: "requires extra usage to be enabled for this account",
|
||||
@@ -45,17 +38,38 @@ const FAST_MODE_UNAVAILABLE_EXPLANATIONS: Partial<Record<FastModeDisabledReason,
|
||||
not_first_party: "not available on this API provider",
|
||||
disabled_by_env: "disabled by environment configuration",
|
||||
network_error: "eligibility could not be verified (network error)",
|
||||
preference: "not enabled for your organization or API gateway",
|
||||
unknown: "currently unavailable",
|
||||
};
|
||||
|
||||
/** Reasons that are never explained:
|
||||
* - `sdk_opt_in_required`: every SDK session starts here (the toggle IS the
|
||||
* opt-in), so it describes the default, not a problem.
|
||||
* - `pending`: eligibility is still resolving; the next report supersedes it. */
|
||||
const FAST_MODE_UNEXPLAINED_REASONS: ReadonlySet<FastModeDisabledReason> = new Set([
|
||||
"sdk_opt_in_required",
|
||||
"pending",
|
||||
]);
|
||||
|
||||
/** The user-facing explanation of a `fast_mode_disabled_reason`: `undefined`
|
||||
* for no reason, `sdk_opt_in_required` or `pending`; the table text when there
|
||||
* is one; else a generic text naming the code (a reason a newer SDK added). */
|
||||
export function explainFastModeDisabledReason(
|
||||
reason: FastModeDisabledReason | undefined,
|
||||
): string | undefined {
|
||||
if (reason === undefined || FAST_MODE_UNEXPLAINED_REASONS.has(reason)) return undefined;
|
||||
return FAST_MODE_UNAVAILABLE_EXPLANATIONS[reason] ?? `not available (${reason})`;
|
||||
}
|
||||
|
||||
/** Normalize an SDK-reported `fast_mode_disabled_reason` to the one we retain:
|
||||
* a reason we have an explanation for, else `undefined`. Keeping only
|
||||
* explainable reasons means state comparisons (see `fastModeStateChange`) track
|
||||
* exactly what the user can see, so routine reports like
|
||||
* `sdk_opt_in_required` never churn the config option. */
|
||||
* a reason {@link explainFastModeDisabledReason} explains (including codes a
|
||||
* newer SDK adds), else `undefined`. State comparisons (see
|
||||
* `fastModeStateChange`) then track exactly what the user can see, so
|
||||
* `sdk_opt_in_required` and `pending` never churn the config option. */
|
||||
export function normalizeFastModeDisabledReason(
|
||||
reason: FastModeDisabledReason | undefined,
|
||||
): FastModeDisabledReason | undefined {
|
||||
return reason && FAST_MODE_UNAVAILABLE_EXPLANATIONS[reason] ? reason : undefined;
|
||||
return explainFastModeDisabledReason(reason) !== undefined ? reason : undefined;
|
||||
}
|
||||
|
||||
/** Build the Fast mode config option. When the Client supports boolean config
|
||||
@@ -73,9 +87,7 @@ export function createFastModeConfigOption(
|
||||
useBooleanOption: boolean,
|
||||
disabledReason?: FastModeDisabledReason,
|
||||
): SessionConfigOption {
|
||||
const explanation = enabled
|
||||
? undefined
|
||||
: disabledReason && FAST_MODE_UNAVAILABLE_EXPLANATIONS[disabledReason];
|
||||
const explanation = enabled ? undefined : explainFastModeDisabledReason(disabledReason);
|
||||
const base = {
|
||||
id: FAST_MODE_CONFIG_ID,
|
||||
name: "Fast mode",
|
||||
@@ -141,15 +153,17 @@ export type FastModeOptionState = {
|
||||
* off), or the same state with the same explainable reason.
|
||||
*
|
||||
* `reason` is the SDK's `fast_mode_disabled_reason`, reported alongside the
|
||||
* state. Only explainable reasons are retained (see
|
||||
* {@link normalizeFastModeDisabledReason}), so a routine
|
||||
* `sdk_opt_in_required` report on every turn's result can't churn the
|
||||
* option, while a real blocker updates the description even when the
|
||||
* state. Every reason except `sdk_opt_in_required` and `pending` is retained
|
||||
* (see {@link normalizeFastModeDisabledReason}; unknown future codes get the
|
||||
* generic text), so `sdk_opt_in_required` on every turn's result can't churn
|
||||
* the option, while a real blocker updates the description even when the
|
||||
* toggle's own value is unchanged.
|
||||
*
|
||||
* `notice` is set when the user had Fast mode on and the SDK turned it off
|
||||
* for an explainable reason: the description carries the same explanation,
|
||||
* but a toggle silently snapping back is worth saying out loud once. */
|
||||
* with a retained reason: the description carries the same explanation, but a
|
||||
* toggle silently snapping back is worth saying out loud once. Off with no
|
||||
* reason, `sdk_opt_in_required` or `pending` stays quiet: a `result` can carry
|
||||
* the turn-start snapshot, so a notice there would falsely say "turned off". */
|
||||
export function fastModeStateChange(
|
||||
current: { enabled: boolean; disabledReason?: FastModeDisabledReason },
|
||||
state: FastModeState | undefined,
|
||||
@@ -163,8 +177,7 @@ export function fastModeStateChange(
|
||||
// so it can't decorate the option the next time fast mode goes off.
|
||||
const disabledReason = enabled ? undefined : normalizeFastModeDisabledReason(reason);
|
||||
if (enabled === current.enabled && disabledReason === current.disabledReason) return undefined;
|
||||
const explanation =
|
||||
disabledReason !== undefined ? FAST_MODE_UNAVAILABLE_EXPLANATIONS[disabledReason] : undefined;
|
||||
const explanation = explainFastModeDisabledReason(disabledReason);
|
||||
if (!current.enabled || enabled || explanation === undefined) {
|
||||
return { enabled, disabledReason };
|
||||
}
|
||||
|
||||
@@ -20,7 +20,10 @@
|
||||
* reached the call is that message too: the sender's outgoing view, at the
|
||||
* call, and the child's incoming one, in the child's history. A child's
|
||||
* history follows its launch, so a call to a child from its parent is always
|
||||
* delivered first. Any other SendMessage call stays a tool call.
|
||||
* delivered first. Any other SendMessage call stays a tool call. As live, a
|
||||
* TaskStop or SendMessage call that the client is shown and that names a
|
||||
* replayed child by its agent id carries the RFD's `session` reference to it
|
||||
* (`ReplayTranscript.withTarget`).
|
||||
*
|
||||
* Right after its announcement, each child gets its `model` option: the model
|
||||
* of its first response, in its transcript. A later response with another
|
||||
@@ -50,6 +53,8 @@ import {
|
||||
rfdLabels,
|
||||
sameDirectory,
|
||||
type SubagentModels,
|
||||
targetAgentId,
|
||||
withSessionReference,
|
||||
type WorkState,
|
||||
} from "./native-subagents.js";
|
||||
import { noticeUpdate } from "./session-notices.js";
|
||||
@@ -116,9 +121,29 @@ export class ReplayTranscript<Child extends ReplayChild = ReplayChild> {
|
||||
readonly omitted = new Set<string>();
|
||||
/** The SubagentHandback calls, by tool use id. */
|
||||
readonly handbacks = new Map<string, ReplayHandback>();
|
||||
/** The agent that each TaskStop or SendMessage call names (see `targetAgentId`), by tool use id. */
|
||||
private readonly targets = new Map<string, string>();
|
||||
|
||||
constructor(private readonly history: SubagentHistory) {}
|
||||
|
||||
/**
|
||||
* `notification`, a replayed tool call update that the client is shown on
|
||||
* the session `shownOn`, with the RFD's `session` reference when the call
|
||||
* names a child of another session by its agent id, as live
|
||||
* (`NativeSubagentRuntime.withTarget`). Only a child that the replay
|
||||
* announced before the update is referenced.
|
||||
*/
|
||||
withTarget(notification: AcpSessionNotification, shownOn: string): AcpSessionNotification {
|
||||
const { update } = notification;
|
||||
if (update.sessionUpdate !== "tool_call" && update.sessionUpdate !== "tool_call_update") {
|
||||
return notification;
|
||||
}
|
||||
const agentId = this.targets.get(update.toolCallId);
|
||||
const child = agentId !== undefined ? this.byAgentId.get(agentId) : undefined;
|
||||
if (!child?.announced || child.sessionId === shownOn) return notification;
|
||||
return withSessionReference(notification, child.sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the launches, the launch results, and the SendMessage calls in
|
||||
* `messages`, the transcript of the session `ownerSessionId`.
|
||||
@@ -127,6 +152,8 @@ export class ReplayTranscript<Child extends ReplayChild = ReplayChild> {
|
||||
for (const message of messages) {
|
||||
for (const block of contentBlocks(message)) {
|
||||
if (block.type === "tool_use" && typeof block.id === "string") {
|
||||
const agentId = targetAgentId(block.name, block.input);
|
||||
if (agentId !== undefined) this.targets.set(block.id, agentId);
|
||||
if (isNativeSubagentControlTool(block.name)) {
|
||||
this.registerLaunch(block.id, inputOf(block), ownerSessionId);
|
||||
} else if (block.name === HANDBACK_TOOL) {
|
||||
@@ -214,6 +241,11 @@ export class RfdSubagentReplay {
|
||||
}
|
||||
|
||||
/** The child that the tool use `toolUseId` launched, when the replay shows it. */
|
||||
/** See {@link ReplayTranscript.withTarget}. */
|
||||
withTarget(notification: AcpSessionNotification, shownOn: string): AcpSessionNotification {
|
||||
return this.transcript.withTarget(notification, shownOn);
|
||||
}
|
||||
|
||||
childOfLaunch(toolUseId: string): ReplayChild | undefined {
|
||||
return this.transcript.children.get(toolUseId);
|
||||
}
|
||||
|
||||
@@ -2012,6 +2012,164 @@ describe("NativeSubagentRuntime in the RFD form", () => {
|
||||
expect(states(published).at(-1)).toEqual({ state: "unknown" });
|
||||
});
|
||||
|
||||
it("reports a child that may go on working unobserved as unknown, without its cancel, and keeps an idle one", async () => {
|
||||
const { runtime, published } = await runningWorker();
|
||||
await runtime.route(launch("launch-2", { description: "Read the docs" }), async () => {});
|
||||
await runtime.taskStarted(
|
||||
{ taskId: "worker-2", toolUseId: "launch-2", subagentType: "Explore" },
|
||||
async () => {},
|
||||
);
|
||||
await runtime.finishTask("worker-2", "completed", async () => {}, "launch-2");
|
||||
published.length = 0;
|
||||
|
||||
// A conversation reset: Claude Code can keep the children running.
|
||||
await runtime.finishAll("failed", async () => {}, { mayStillRun: true });
|
||||
expect(published.map(({ update }) => update)).toEqual([
|
||||
{
|
||||
sessionUpdate: "subagent_update",
|
||||
sessionId: "worker-1",
|
||||
capabilities: null,
|
||||
state: { state: "unknown" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
describe("a tool call that operates on a child", () => {
|
||||
const toolCall = (
|
||||
toolCallId: string,
|
||||
toolName: string,
|
||||
rawInput: Record<string, unknown>,
|
||||
parentToolUseId?: string,
|
||||
): AcpSessionNotification =>
|
||||
({
|
||||
sessionId: "root",
|
||||
update: {
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId,
|
||||
title: toolName,
|
||||
status: "pending",
|
||||
rawInput,
|
||||
content: [{ type: "content", content: { type: "text", text: "input" } }],
|
||||
_meta: {
|
||||
claudeCode: { toolName, ...(parentToolUseId ? { parentToolUseId } : {}) },
|
||||
},
|
||||
},
|
||||
}) as AcpSessionNotification;
|
||||
const toolResult = (
|
||||
toolCallId: string,
|
||||
toolName: string,
|
||||
content?: unknown[],
|
||||
): AcpSessionNotification =>
|
||||
({
|
||||
sessionId: "root",
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId,
|
||||
status: "completed",
|
||||
...(content ? { content } : {}),
|
||||
_meta: { claudeCode: { toolName } },
|
||||
},
|
||||
}) as AcpSessionNotification;
|
||||
const text = { type: "content", content: { type: "text", text: "Stopped" } };
|
||||
const reference = { type: "session", sessionId: "worker-1" };
|
||||
const contentOf = (notification: AcpSessionNotification | null) =>
|
||||
(notification?.update as { content?: unknown[] } | undefined)?.content;
|
||||
|
||||
it("references the announced child that a TaskStop call names by its agent id", async () => {
|
||||
const { runtime } = await runningWorker();
|
||||
|
||||
expect(
|
||||
contentOf(
|
||||
await runtime.route(
|
||||
toolCall("stop-1", "TaskStop", { task_id: "worker-1" }),
|
||||
async () => {},
|
||||
),
|
||||
),
|
||||
).toEqual([{ type: "content", content: { type: "text", text: "input" } }, reference]);
|
||||
// An update without content would replace nothing, so it carries none.
|
||||
const progress = toolResult("stop-1", "TaskStop");
|
||||
(progress.update as { status?: string }).status = "in_progress";
|
||||
expect(contentOf(await runtime.route(progress, async () => {}))).toBeUndefined();
|
||||
// The result's content replaces the first report's, so it has the reference again.
|
||||
expect(
|
||||
contentOf(await runtime.route(toolResult("stop-1", "TaskStop", [text]), async () => {})),
|
||||
).toEqual([text, reference]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a task that is no child", "TaskStop", { task_id: "bash_1" }],
|
||||
["a child named by its name, which Claude Code resolves", "TaskStop", { task_id: "builder" }],
|
||||
["another tool with the same input", "Monitor", { task_id: "worker-1" }],
|
||||
])("references nothing for %s", async (_, toolName, input) => {
|
||||
const { runtime } = await runningWorker({ name: "builder" });
|
||||
|
||||
expect(
|
||||
contentOf(await runtime.route(toolCall("call-1", toolName, input), async () => {})),
|
||||
).toEqual([{ type: "content", content: { type: "text", text: "input" } }]);
|
||||
});
|
||||
|
||||
it("references no child that is not announced yet, as the RFD requires", async () => {
|
||||
const { runtime } = await runningWorker();
|
||||
// A nested child whose launch frame, which names its parent, has not come.
|
||||
await runtime.taskStarted(
|
||||
{ taskId: "worker-2", toolUseId: "launch-2", subagentType: "Explore" },
|
||||
async () => {},
|
||||
);
|
||||
await runtime.route(launch("launch-2", { description: "Dig" }, "launch-9"), async () => {});
|
||||
|
||||
expect(
|
||||
contentOf(
|
||||
await runtime.route(
|
||||
toolCall("stop-2", "TaskStop", { task_id: "worker-2" }),
|
||||
async () => {},
|
||||
),
|
||||
),
|
||||
).toEqual([{ type: "content", content: { type: "text", text: "input" } }]);
|
||||
});
|
||||
|
||||
it("references the child from a SendMessage call to it that stays a tool call", async () => {
|
||||
const { runtime } = await runningWorker();
|
||||
|
||||
// The message resumes no child, so the call is shown at its end.
|
||||
await expect(
|
||||
runtime.route(toolCall("send-1", "SendMessage", { to: "worker-1" }), async () => {}),
|
||||
).resolves.toBeNull();
|
||||
const shown = await runtime.route(
|
||||
toolResult("send-1", "SendMessage", [text]),
|
||||
async () => {},
|
||||
);
|
||||
expect(shown).toMatchObject({ sessionId: "root", update: { sessionUpdate: "tool_call" } });
|
||||
expect(contentOf(shown)).toEqual([text, reference]);
|
||||
});
|
||||
|
||||
it("references the child from its launch call that a permission request showed", async () => {
|
||||
const { runtime } = await runningWorker();
|
||||
|
||||
const shown = await runtime.route(
|
||||
launchResult("launch-1", "completed", "Done"),
|
||||
async () => {},
|
||||
"root",
|
||||
);
|
||||
expect(contentOf(shown)).toEqual([
|
||||
{ type: "content", content: { type: "text", text: "Done" } },
|
||||
reference,
|
||||
]);
|
||||
});
|
||||
|
||||
it("references nothing in AIR's draft", async () => {
|
||||
const runtime = new NativeSubagentRuntime(
|
||||
"air",
|
||||
"root",
|
||||
sessionWith(child({ sessionId: "worker-1", announced: true })),
|
||||
async () => {},
|
||||
{ log: () => {} },
|
||||
);
|
||||
const call = toolCall("stop-1", "TaskStop", { task_id: "worker-1" });
|
||||
|
||||
await expect(runtime.route(call, async () => {})).resolves.toEqual(call);
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a child as requires_action while its requests are open", async () => {
|
||||
const { runtime, published } = await runningWorker();
|
||||
const first = Promise.withResolvers<string>();
|
||||
|
||||
@@ -28,11 +28,8 @@ import { nodeToWebWritable, nodeToWebReadable } from "../../utils.js";
|
||||
import { createPostToolUseHook, markdownEscape, toDisplayPath } from "../../tools.js";
|
||||
import { toolUpdateFromDiffToolResponse } from "../../diff.js";
|
||||
import { ToolCallFieldTracker } from "../../tool-calls/field-tracker.js";
|
||||
import {
|
||||
ClaudeAcpAgent,
|
||||
computeSessionFingerprint,
|
||||
runPromptWithCancellation,
|
||||
} from "../../v1/acp-agent.js";
|
||||
import { ClaudeAcpAgent, runPromptWithCancellation } from "../../v1/acp-agent.js";
|
||||
import { computeSessionFingerprint } from "../../v1/session-fingerprint.js";
|
||||
import type { SteerRequest } from "../../v1/steering.js";
|
||||
import { buildConfigOptions, discoverCustomAgents } from "../../session-config-options.js";
|
||||
import { createFastModeConfigOption } from "../../session-fast-mode.js";
|
||||
@@ -4205,7 +4202,13 @@ describe("subagent transcript replay", () => {
|
||||
];
|
||||
}
|
||||
case "tool_call":
|
||||
return [`${on} tool_call ${u.toolCallId}`];
|
||||
return [
|
||||
`${on} tool_call ${u.toolCallId}` +
|
||||
(u.content ?? [])
|
||||
.filter((item: any) => item.type === "session")
|
||||
.map((item: any) => ` session=${item.sessionId}`)
|
||||
.join(""),
|
||||
];
|
||||
case "config_option_update":
|
||||
return traceModels
|
||||
? u.configOptions.map(
|
||||
@@ -4640,6 +4643,37 @@ describe("subagent transcript replay", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("references a replayed child from a TaskStop call that names it by its agent id", async () => {
|
||||
const trace = await replayRfd(
|
||||
[
|
||||
user("u1", "Go"),
|
||||
// The child is not announced yet: a call that names it references nothing.
|
||||
assistant("a1", [toolUse("toolu_early", "TaskStop", { task_id: "abc123" })]),
|
||||
user("u2", [toolResult("toolu_early", "No such task")]),
|
||||
assistant("a2", [
|
||||
toolUse("toolu_agent", "Agent", { subagent_type: "Explore", prompt: "Look around" }),
|
||||
]),
|
||||
user("u3", [toolResult("toolu_agent", "Async agent launched")]),
|
||||
assistant("a3", [toolUse("toolu_stop", "TaskStop", { task_id: "abc123" })]),
|
||||
user("u4", [toolResult("toolu_stop", "Stopped abc123")]),
|
||||
],
|
||||
{
|
||||
abc123: {
|
||||
meta: { agentType: "Explore", toolUseId: "toolu_agent", requestShape: "background" },
|
||||
records: [user("c1", "Look around")],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(trace.filter((line) => line.includes("tool_call"))).toEqual([
|
||||
"root tool_call toolu_early",
|
||||
"root tool_call toolu_stop session=abc123",
|
||||
]);
|
||||
expect(
|
||||
trace.findIndex((line) => line.startsWith("root subagent_update abc123")),
|
||||
).toBeLessThan(trace.indexOf("root tool_call toolu_stop session=abc123"));
|
||||
});
|
||||
|
||||
it("replays a child's hand-back as its message to the parent, instead of the launch's result", async () => {
|
||||
const trace = await replayRfd(
|
||||
[
|
||||
@@ -9112,6 +9146,66 @@ describe("subagent permission attribution (issue #851)", () => {
|
||||
state: { state: "idle" },
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a child that still works at a conversation reset as unknown, without its cancel", async () => {
|
||||
const updates = await run([
|
||||
{ ...taskStarted("agent-42", "toolu_parent"), subagent_type: "Explore" },
|
||||
{
|
||||
type: "conversation_reset",
|
||||
new_conversation_id: randomUUID(),
|
||||
uuid: randomUUID(),
|
||||
session_id: "test-session",
|
||||
},
|
||||
// The reset forgot the child: its later end changes nothing.
|
||||
taskNotification("toolu_parent", "completed"),
|
||||
]);
|
||||
|
||||
expect(subagentTraffic(updates).at(-1)?.update).toEqual({
|
||||
sessionUpdate: "subagent_update",
|
||||
sessionId: "agent-42",
|
||||
capabilities: null,
|
||||
state: { state: "unknown" },
|
||||
});
|
||||
});
|
||||
|
||||
it("references an announced child from a TaskStop call that names it by its agent id", async () => {
|
||||
const toolResult = (toolUseId: string, content: string) => ({
|
||||
type: "user",
|
||||
parent_tool_use_id: null,
|
||||
uuid: randomUUID(),
|
||||
session_id: "test-session",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [{ type: "tool_result", tool_use_id: toolUseId, content }],
|
||||
},
|
||||
});
|
||||
const updates = await run([
|
||||
{ ...taskStarted("agent-42", "toolu_parent"), subagent_type: "Explore" },
|
||||
rootToolUse("toolu_stop", "TaskStop", { task_id: "agent-42" }),
|
||||
toolResult("toolu_stop", "Stopped agent-42"),
|
||||
// A task that is no child session.
|
||||
rootToolUse("toolu_bash", "TaskStop", { task_id: "bash_1" }),
|
||||
toolResult("toolu_bash", "Stopped bash_1"),
|
||||
]);
|
||||
|
||||
const references = (toolCallId: string) =>
|
||||
updates.flatMap(({ sessionId, update }) =>
|
||||
(update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update") &&
|
||||
update.toolCallId === toolCallId &&
|
||||
update.content != null
|
||||
? [
|
||||
`${sessionId} ${JSON.stringify(update.content.filter((item) => item.type === "session"))}`,
|
||||
]
|
||||
: [],
|
||||
);
|
||||
// The first report and the result both carry content, which replaces
|
||||
// what the client holds, so both carry the reference.
|
||||
expect(references("toolu_stop")).toEqual([
|
||||
'test-session [{"type":"session","sessionId":"agent-42"}]',
|
||||
'test-session [{"type":"session","sessionId":"agent-42"}]',
|
||||
]);
|
||||
expect(references("toolu_bash")).toEqual(["test-session []", "test-session []"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("ends the root's tool calls at a cancel, and sends nothing to a child that the cancel ended (AIR)", async () => {
|
||||
|
||||
@@ -16,6 +16,14 @@ import {
|
||||
FAST_MODE_OFF,
|
||||
} from "../../session-fast-mode.js";
|
||||
import type { AcpClient } from "../../acp-client.js";
|
||||
import { Pushable } from "../../utils.js";
|
||||
import { initializeClient } from "../helpers.js";
|
||||
import {
|
||||
mockSessionState,
|
||||
successfulResultMessage,
|
||||
userEcho,
|
||||
wrapQuery,
|
||||
} from "./session-doubles.js";
|
||||
|
||||
const MODES = {
|
||||
currentModeId: "default",
|
||||
@@ -33,6 +41,8 @@ const MODEL_INFOS: ModelInfo[] = [
|
||||
{ value: "claude-opus-4-8", displayName: "Claude Opus", description: "Most capable" },
|
||||
];
|
||||
|
||||
const FUTURE_REASON = "some_future_reason" as FastModeDisabledReason;
|
||||
|
||||
describe("createFastModeConfigOption", () => {
|
||||
it("produces a native boolean toggle when the client opted in", () => {
|
||||
expect(createFastModeConfigOption(true, true)).toEqual({
|
||||
@@ -45,20 +55,40 @@ describe("createFastModeConfigOption", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("explains a blocking disabled reason while the toggle reads off", () => {
|
||||
const option = createFastModeConfigOption(false, true, "free");
|
||||
expect(option.description).toBe(
|
||||
"Faster responses on supported models — not available on the free plan",
|
||||
);
|
||||
});
|
||||
/**
|
||||
* Scenario:
|
||||
* 1. Build the option with the toggle off and a disabled reason.
|
||||
* Expected:
|
||||
* - The description is the base text plus the reason's explanation.
|
||||
* - A code the table lacks gets the generic text naming the code.
|
||||
*/
|
||||
it.each([
|
||||
["free", "not available on the free plan"],
|
||||
["preference", "not enabled for your organization or API gateway"],
|
||||
["unknown", "currently unavailable"],
|
||||
[FUTURE_REASON, "not available (some_future_reason)"],
|
||||
] as [FastModeDisabledReason, string][])(
|
||||
"explains the disabled reason %s while the toggle reads off",
|
||||
(reason, explanation) => {
|
||||
const option = createFastModeConfigOption(false, true, reason);
|
||||
expect(option.description).toBe(`Faster responses on supported models — ${explanation}`);
|
||||
},
|
||||
);
|
||||
|
||||
it("ignores routine and on-state reasons", () => {
|
||||
/**
|
||||
* Scenario:
|
||||
* 1. Build the option, toggle off, with `sdk_opt_in_required` or `pending`.
|
||||
* 2. Build the option, toggle on, with `free`.
|
||||
* Expected:
|
||||
* - The description stays the base text in all three cases.
|
||||
*/
|
||||
it("ignores unexplained and on-state reasons", () => {
|
||||
// Every SDK session starts at `sdk_opt_in_required` — the toggle IS the
|
||||
// opt-in, so it must not read as a blocker.
|
||||
expect(createFastModeConfigOption(false, true, "sdk_opt_in_required").description).toBe(
|
||||
"Faster responses on supported models",
|
||||
);
|
||||
expect(createFastModeConfigOption(false, true, "preference").description).toBe(
|
||||
expect(createFastModeConfigOption(false, true, "pending").description).toBe(
|
||||
"Faster responses on supported models",
|
||||
);
|
||||
// A reason riding an `on` state isn't blocking anything right now.
|
||||
@@ -133,25 +163,38 @@ describe("fastModeStateEnabled", () => {
|
||||
});
|
||||
|
||||
describe("normalizeFastModeDisabledReason", () => {
|
||||
it("retains only reasons we have an explanation for", () => {
|
||||
expect(normalizeFastModeDisabledReason("free")).toBe("free");
|
||||
expect(normalizeFastModeDisabledReason("extra_usage_disabled")).toBe("extra_usage_disabled");
|
||||
expect(normalizeFastModeDisabledReason("model_not_allowed")).toBe("model_not_allowed");
|
||||
expect(normalizeFastModeDisabledReason("not_first_party")).toBe("not_first_party");
|
||||
expect(normalizeFastModeDisabledReason("disabled_by_env")).toBe("disabled_by_env");
|
||||
expect(normalizeFastModeDisabledReason("network_error")).toBe("network_error");
|
||||
/**
|
||||
* Scenario:
|
||||
* 1. Normalize each explained reason, including `preference`, `unknown` and a future code.
|
||||
* Expected:
|
||||
* - Each reason is returned unchanged.
|
||||
*/
|
||||
it.each([
|
||||
"free",
|
||||
"extra_usage_disabled",
|
||||
"model_not_allowed",
|
||||
"not_first_party",
|
||||
"disabled_by_env",
|
||||
"network_error",
|
||||
"preference",
|
||||
"unknown",
|
||||
FUTURE_REASON,
|
||||
] as FastModeDisabledReason[])("retains the explained reason %s", (reason) => {
|
||||
expect(normalizeFastModeDisabledReason(reason)).toBe(reason);
|
||||
});
|
||||
|
||||
it("drops routine states and unknown values", () => {
|
||||
expect(normalizeFastModeDisabledReason("sdk_opt_in_required")).toBeUndefined();
|
||||
expect(normalizeFastModeDisabledReason("preference")).toBeUndefined();
|
||||
expect(normalizeFastModeDisabledReason("pending")).toBeUndefined();
|
||||
expect(normalizeFastModeDisabledReason("unknown")).toBeUndefined();
|
||||
expect(normalizeFastModeDisabledReason(undefined)).toBeUndefined();
|
||||
expect(
|
||||
normalizeFastModeDisabledReason("some_future_reason" as FastModeDisabledReason),
|
||||
).toBeUndefined();
|
||||
});
|
||||
/**
|
||||
* Scenario:
|
||||
* 1. Normalize `sdk_opt_in_required`, `pending` and `undefined`.
|
||||
* Expected:
|
||||
* - Each normalizes to `undefined`.
|
||||
*/
|
||||
it.each(["sdk_opt_in_required", "pending", undefined] as (FastModeDisabledReason | undefined)[])(
|
||||
"drops the unexplained reason %s",
|
||||
(reason) => {
|
||||
expect(normalizeFastModeDisabledReason(reason)).toBeUndefined();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("buildConfigOptions Fast mode", () => {
|
||||
@@ -403,29 +446,61 @@ describe("syncFastModeState (SDK-driven state changes)", () => {
|
||||
expect(sessionUpdates).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("explains a blocking reason once when the SDK refuses a toggle the user turned on", async () => {
|
||||
/**
|
||||
* Scenario:
|
||||
* 1. The toggle is on and the SDK reports `off` with a reason.
|
||||
* 2. The SDK reports the same `off` and reason again.
|
||||
* Expected:
|
||||
* - The toggle is cleared and the reason retained.
|
||||
* - One transcript line carries the explanation, then one `config_option_update`.
|
||||
* - The repeat report sends nothing.
|
||||
*/
|
||||
it.each([
|
||||
["extra_usage_disabled", "requires extra usage to be enabled for this account"],
|
||||
["preference", "not enabled for your organization or API gateway"],
|
||||
["unknown", "currently unavailable"],
|
||||
[FUTURE_REASON, "not available (some_future_reason)"],
|
||||
] as [FastModeDisabledReason, string][])(
|
||||
"explains the blocking reason %s once when the SDK refuses a toggle the user turned on",
|
||||
async (reason, explanation) => {
|
||||
const { sync, session, sessionUpdates } = setup({ fastModeEnabled: true, withOption: true });
|
||||
|
||||
await sync(SESSION_ID, session, "off", reason);
|
||||
|
||||
expect(session.fastModeEnabled).toBe(false);
|
||||
expect(session.fastModeDisabledReason).toBe(reason);
|
||||
expect(sessionUpdates).toHaveLength(2);
|
||||
expect(sessionUpdates[0].update).toEqual({
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: {
|
||||
type: "text",
|
||||
text: `\n\n> **Fast mode turned off:** ${explanation}.\n\n`,
|
||||
},
|
||||
});
|
||||
expect(sessionUpdates[1].update).toMatchObject({ sessionUpdate: "config_option_update" });
|
||||
expect(session.configOptions).toContainEqual(createFastModeConfigOption(false, true, reason));
|
||||
|
||||
// The same report again changes nothing the user can see: no repeat notice.
|
||||
await sync(SESSION_ID, session, "off", reason);
|
||||
expect(sessionUpdates).toHaveLength(2);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Scenario:
|
||||
* 1. The toggle is on and the SDK reports `off` with `sdk_opt_in_required`.
|
||||
* Expected:
|
||||
* - The toggle is cleared and no reason is retained.
|
||||
* - Only a `config_option_update` is sent: no notice or transcript line.
|
||||
*/
|
||||
it("clears the toggle quietly when the SDK reports off with sdk_opt_in_required", async () => {
|
||||
const { sync, session, sessionUpdates } = setup({ fastModeEnabled: true, withOption: true });
|
||||
|
||||
await sync(SESSION_ID, session, "off", "extra_usage_disabled");
|
||||
await sync(SESSION_ID, session, "off", "sdk_opt_in_required");
|
||||
|
||||
expect(session.fastModeEnabled).toBe(false);
|
||||
expect(session.fastModeDisabledReason).toBe("extra_usage_disabled");
|
||||
expect(sessionUpdates).toHaveLength(2);
|
||||
expect(sessionUpdates[0].update).toEqual({
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: {
|
||||
type: "text",
|
||||
text: "\n\n> **Fast mode turned off:** requires extra usage to be enabled for this account.\n\n",
|
||||
},
|
||||
});
|
||||
expect(sessionUpdates[1].update).toMatchObject({ sessionUpdate: "config_option_update" });
|
||||
expect(session.configOptions).toContainEqual(
|
||||
createFastModeConfigOption(false, true, "extra_usage_disabled"),
|
||||
);
|
||||
|
||||
// The same report again changes nothing the user can see: no repeat notice.
|
||||
await sync(SESSION_ID, session, "off", "extra_usage_disabled");
|
||||
expect(sessionUpdates).toHaveLength(2);
|
||||
expect(session.fastModeDisabledReason).toBeUndefined();
|
||||
expect(sessionUpdates.map((n) => n.update.sessionUpdate)).toEqual(["config_option_update"]);
|
||||
});
|
||||
|
||||
it("explains the flip as a live notice to a client on the notice contract", async () => {
|
||||
@@ -493,3 +568,89 @@ describe("syncFastModeState (SDK-driven state changes)", () => {
|
||||
expect(disabledCase.sessionUpdates).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Fast mode that Claude Code refuses during a turn", () => {
|
||||
const SESSION_ID = "test-session";
|
||||
|
||||
/** A session whose Claude Code answers the first prompt with a `system`/init
|
||||
* that reports Fast mode off with `reason`, as it does on every turn. */
|
||||
async function setupTurn(reason: FastModeDisabledReason) {
|
||||
const updates: SessionNotification[] = [];
|
||||
const client = {
|
||||
sessionUpdate: async (n: SessionNotification) => {
|
||||
updates.push(n);
|
||||
},
|
||||
} as unknown as AcpClient;
|
||||
const agent = new ClaudeAcpAgent(client, { log: () => {}, error: () => {} });
|
||||
await initializeClient(agent, { session: { configOptions: { boolean: {} } } });
|
||||
|
||||
const input = new Pushable<any>();
|
||||
async function* messages() {
|
||||
const { value: prompt } = await input[Symbol.asyncIterator]().next();
|
||||
yield userEcho(prompt);
|
||||
yield {
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
fast_mode_state: "off",
|
||||
fast_mode_disabled_reason: reason,
|
||||
session_id: SESSION_ID,
|
||||
};
|
||||
yield successfulResultMessage();
|
||||
yield { type: "system", subtype: "session_state_changed", state: "idle" };
|
||||
}
|
||||
const query = Object.assign(wrapQuery(messages()), { applyFlagSettings: async () => {} });
|
||||
agent.sessions[SESSION_ID] = mockSessionState({
|
||||
query,
|
||||
input,
|
||||
fastModeEnabled: false,
|
||||
configOptions: [createFastModeConfigOption(false, true)],
|
||||
});
|
||||
return { agent, updates };
|
||||
}
|
||||
|
||||
/**
|
||||
* Scenario:
|
||||
* 1. The client, without the notices capability, turns Fast mode on.
|
||||
* 2. It sends a prompt, and Claude Code's init reports Fast mode off, reason `preference`.
|
||||
* Expected:
|
||||
* - The transcript gets one line that explains the organization or gateway has not enabled it.
|
||||
* - Then one `config_option_update` where `fast` reads off, with the explanation in its description.
|
||||
*/
|
||||
it("explains a Fast mode that the organization or gateway has not enabled", async () => {
|
||||
const { agent, updates } = await setupTurn("preference");
|
||||
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "fast",
|
||||
type: "boolean",
|
||||
value: true,
|
||||
});
|
||||
updates.length = 0;
|
||||
await agent.prompt({ sessionId: SESSION_ID, prompt: [{ type: "text", text: "hi" }] });
|
||||
|
||||
const explained = updates
|
||||
.map((n) => n.update)
|
||||
.filter((u) => u.sessionUpdate !== "usage_update");
|
||||
expect(explained).toEqual([
|
||||
{
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: {
|
||||
type: "text",
|
||||
text: "\n\n> **Fast mode turned off:** not enabled for your organization or API gateway.\n\n",
|
||||
},
|
||||
},
|
||||
{
|
||||
sessionUpdate: "config_option_update",
|
||||
configOptions: [
|
||||
expect.objectContaining({
|
||||
id: "fast",
|
||||
currentValue: false,
|
||||
description: expect.stringMatching(
|
||||
/ — not enabled for your organization or API gateway$/,
|
||||
),
|
||||
}),
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1445,6 +1445,51 @@ describe("ACP v2 sessions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Scenario:
|
||||
* 1. The client turns Fast mode on and sends a prompt.
|
||||
* 2. Claude Code's `system`/init of that turn reports Fast mode off, reason `preference`.
|
||||
* Expected:
|
||||
* - One warning notice explains that the organization or gateway has not enabled it.
|
||||
* - The `fast` option reads off, and its description ends with the explanation.
|
||||
*/
|
||||
it("explains a Fast mode that the organization or gateway has not enabled", async () => {
|
||||
scriptTurns(async function* (options) {
|
||||
yield fastModeInit(options, "off", "preference");
|
||||
yield assistantText(options, "Done.");
|
||||
yield result(options);
|
||||
yield idleState(options);
|
||||
});
|
||||
const client = v2Client();
|
||||
await client.app.connectWith(connectRouter(), async (agent) => {
|
||||
await initializeV2(agent);
|
||||
const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd });
|
||||
await agent.request(v2.methods.agent.session.setConfigOption, {
|
||||
sessionId,
|
||||
configId: "fast",
|
||||
type: "boolean",
|
||||
value: true,
|
||||
});
|
||||
await agent.request(v2.methods.agent.session.prompt, { sessionId, prompt: text("hi") });
|
||||
await vi.waitFor(() => expect(turnTrace(client.sessionUpdates)).toContain("idle end_turn"));
|
||||
await client.authUpdate(1);
|
||||
});
|
||||
|
||||
expect(client.updates("notice")).toEqual([
|
||||
{
|
||||
sessionUpdate: "notice",
|
||||
severity: "warning",
|
||||
title: "Fast mode turned off",
|
||||
description: "Not enabled for your organization or API gateway.",
|
||||
},
|
||||
]);
|
||||
expect(fastOption(client.updates("config_option_update").at(-1))).toMatchObject({
|
||||
currentValue: false,
|
||||
description:
|
||||
"Faster responses on supported models — not enabled for your organization or API gateway",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a refusal fallback with a notice and follows the model it swapped to", async () => {
|
||||
sdk.initializationResult = async () => ({ models: [OPUS, HAIKU] });
|
||||
scriptTurns(async function* (options) {
|
||||
@@ -1691,6 +1736,23 @@ function stream(options: Options, event: Record<string, unknown>) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Claude Code's per-turn `system`/init, reporting the Fast mode state. */
|
||||
function fastModeInit(options: Options, state: string, reason: string) {
|
||||
return {
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
fast_mode_state: state,
|
||||
fast_mode_disabled_reason: reason,
|
||||
session_id: options.sessionId,
|
||||
uuid: randomUUID(),
|
||||
};
|
||||
}
|
||||
|
||||
/** The `fast` option of a `config_option_update`. */
|
||||
function fastOption(update: { configOptions: { configId: string }[] } | undefined) {
|
||||
return update?.configOptions.find((option) => option.configId === "fast");
|
||||
}
|
||||
|
||||
function idleState(options: Options) {
|
||||
return {
|
||||
type: "system",
|
||||
@@ -6167,6 +6229,62 @@ describe("ACP v2 requests that subagents make", () => {
|
||||
expect(models).toEqual(["claude-sonnet-4-6 Claude Sonnet 4.6", "response"]);
|
||||
});
|
||||
|
||||
it("references a replayed child from a TaskStop call that names it by its agent id", async () => {
|
||||
scriptTurns();
|
||||
const client = v2Client();
|
||||
await client.app.connectWith(connectRouter(), async (agent) => {
|
||||
await initializeV2(agent);
|
||||
const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd });
|
||||
await agent.request(v2.methods.agent.session.close, { sessionId });
|
||||
await writeSubagentHistory(
|
||||
sessionId,
|
||||
[transcriptRecord(sessionId, "r1", null, { role: "user", content: "hi" })],
|
||||
"agent-1",
|
||||
{ agentType: "Explore", toolUseId: "toolu_agent-1" },
|
||||
[transcriptRecord(sessionId, "c1", null, { role: "user", content: "Look around" })],
|
||||
);
|
||||
const toolUse = (id: string, name: string, input: Record<string, unknown>) =>
|
||||
transcriptEntry("assistant", randomUUID(), {
|
||||
id: `msg_${id}`,
|
||||
role: "assistant",
|
||||
content: [{ type: "tool_use", id, name, input }],
|
||||
});
|
||||
const toolResult = (id: string, content: string) =>
|
||||
transcriptEntry("user", randomUUID(), {
|
||||
role: "user",
|
||||
content: [{ type: "tool_result", tool_use_id: id, content }],
|
||||
});
|
||||
vi.mocked(getSessionMessages).mockResolvedValueOnce([
|
||||
// The child is not announced yet: a call that names it references nothing.
|
||||
toolUse("toolu_early", "TaskStop", { task_id: "agent-1" }),
|
||||
toolResult("toolu_early", "No such task"),
|
||||
toolUse("toolu_agent-1", "Agent", { subagent_type: "Explore", prompt: "Look around" }),
|
||||
toolResult("toolu_agent-1", "Done."),
|
||||
toolUse("toolu_stop", "TaskStop", { task_id: "agent-1" }),
|
||||
toolResult("toolu_stop", "Stopped agent-1"),
|
||||
] as unknown as Awaited<ReturnType<typeof getSessionMessages>>);
|
||||
client.sessionUpdates.length = 0;
|
||||
await agent.request(v2.methods.agent.session.resume, {
|
||||
sessionId,
|
||||
cwd,
|
||||
replayFrom: { type: "start" },
|
||||
});
|
||||
await client.authUpdate(1);
|
||||
});
|
||||
|
||||
const references = (toolCallId: string) =>
|
||||
client.updates("tool_call_update").flatMap((update) => {
|
||||
const call = update as { toolCallId: string; content?: Array<{ type: string }> | null };
|
||||
if (call.toolCallId !== toolCallId || call.content == null) return [];
|
||||
return [call.content.filter((item) => item.type === "session")];
|
||||
});
|
||||
expect(references("toolu_stop").length).toBeGreaterThan(0);
|
||||
for (const reference of references("toolu_stop")) {
|
||||
expect(reference).toEqual([{ type: "session", sessionId: "agent-1" }]);
|
||||
}
|
||||
expect(references("toolu_early").flat()).toEqual([]);
|
||||
});
|
||||
|
||||
it("drops a foreground child's consolidated messages after the root's cancel, except the results of its shown tool calls", async () => {
|
||||
const interrupted = Promise.withResolvers<void>();
|
||||
sdk.interrupt = async () => interrupted.resolve();
|
||||
@@ -6284,7 +6402,7 @@ describe("ACP v2 requests that subagents make", () => {
|
||||
).toMatchObject({ status: "cancelled" });
|
||||
});
|
||||
|
||||
it("ends the subagents that work when Claude Code starts a fresh conversation", async () => {
|
||||
it("reports the subagents that work as unknown when Claude Code starts a fresh conversation", async () => {
|
||||
scriptTurns(
|
||||
async function* (options) {
|
||||
yield toolUseMessage(options, "toolu_agent-1", "Agent", {
|
||||
@@ -6340,7 +6458,10 @@ describe("ACP v2 requests that subagents make", () => {
|
||||
switch (update.sessionUpdate) {
|
||||
case "subagent_update":
|
||||
return update.state
|
||||
? [`${on} subagent ${update.sessionId} ${state(update.state)}`]
|
||||
? [
|
||||
`${on} subagent ${update.sessionId} ${state(update.state)}` +
|
||||
(update.capabilities === null ? ", no capabilities" : ""),
|
||||
]
|
||||
: [];
|
||||
case "state_update": {
|
||||
const listed = (
|
||||
@@ -6358,13 +6479,13 @@ describe("ACP v2 requests that subagents make", () => {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
// The child's run ends with no stop reason: nothing says how. Its tool
|
||||
// call ends first, as v2 asks.
|
||||
// The child may go on working, and the adapter no longer follows it: it
|
||||
// is `unknown`, not idle, and it cannot be cancelled. Nothing says that
|
||||
// its tool call ended, so the call keeps its status.
|
||||
expect(trace).toEqual([
|
||||
"root running []",
|
||||
"agent-1 toolu_ls failed",
|
||||
"root subagent agent-1 idle",
|
||||
"agent-1 idle []",
|
||||
"root subagent agent-1 unknown, no capabilities",
|
||||
"agent-1 unknown []",
|
||||
"root idle end_turn []",
|
||||
]);
|
||||
});
|
||||
@@ -6506,19 +6627,78 @@ describe("ACP v2 requests that subagents make", () => {
|
||||
)!.sessionId;
|
||||
|
||||
expect(client.permissionRequests).toHaveLength(1);
|
||||
expect(
|
||||
client.sessionUpdates.filter(
|
||||
({ sessionId, update }) =>
|
||||
sessionId === root &&
|
||||
update.sessionUpdate === "tool_call_update" &&
|
||||
(update as { toolCallId: string }).toolCallId === "toolu_send" &&
|
||||
(update as { status?: string }).status === "completed",
|
||||
),
|
||||
).toHaveLength(completedUpdates);
|
||||
const completed = client.sessionUpdates.filter(
|
||||
({ sessionId, update }) =>
|
||||
sessionId === root &&
|
||||
update.sessionUpdate === "tool_call_update" &&
|
||||
(update as { toolCallId: string }).toolCallId === "toolu_send" &&
|
||||
(update as { status?: string }).status === "completed",
|
||||
);
|
||||
expect(completed).toHaveLength(completedUpdates);
|
||||
// The call that stays shown references the child that it resumed.
|
||||
for (const { update } of completed) {
|
||||
expect((update as { content?: unknown[] }).content?.at(-1)).toEqual({
|
||||
type: "session",
|
||||
sessionId: "agent-1",
|
||||
});
|
||||
}
|
||||
expect(sessionMessages(client, root).slice(3)).toEqual(delivery);
|
||||
},
|
||||
);
|
||||
|
||||
it("references an announced child from a TaskStop call that names it by its agent id", async () => {
|
||||
scriptTurns(async function* (options) {
|
||||
// No child is announced yet: a call that names one references nothing.
|
||||
yield toolUseMessage(options, "toolu_early", "TaskStop", { task_id: "agent-1" });
|
||||
yield toolResultMessage(options, "toolu_early", { content: "No such task" });
|
||||
yield toolUseMessage(options, "toolu_agent-1", "Agent", {
|
||||
description: "Explore the project",
|
||||
prompt: "Look around",
|
||||
run_in_background: true,
|
||||
});
|
||||
yield taskStarted(options, "agent-1", { prompt: "Look around", is_backgrounded: true });
|
||||
yield toolResultMessage(options, "toolu_agent-1", { content: "Launched." });
|
||||
yield toolUseMessage(options, "toolu_stop", "TaskStop", { task_id: "agent-1" });
|
||||
yield toolResultMessage(options, "toolu_stop", { content: "Stopped agent-1" });
|
||||
yield taskNotification(options, "agent-1", { status: "stopped" });
|
||||
// A task that is no child session.
|
||||
yield toolUseMessage(options, "toolu_bash", "TaskStop", { task_id: "bash_1" });
|
||||
yield toolResultMessage(options, "toolu_bash", { content: "Stopped bash_1" });
|
||||
yield result(options);
|
||||
yield idleState(options);
|
||||
});
|
||||
const client = await promptToIdle(v2Client());
|
||||
|
||||
const references = (toolCallId: string) =>
|
||||
client.updates("tool_call_update").flatMap((update) => {
|
||||
const call = update as { toolCallId: string; content?: Array<{ type: string }> | null };
|
||||
if (call.toolCallId !== toolCallId || call.content == null) return [];
|
||||
return [call.content.filter((item) => item.type === "session")];
|
||||
});
|
||||
// Every report of the call that carries content carries the reference:
|
||||
// a report's content replaces what the client holds.
|
||||
expect(references("toolu_stop").length).toBeGreaterThan(0);
|
||||
for (const reference of references("toolu_stop")) {
|
||||
expect(reference).toEqual([{ type: "session", sessionId: "agent-1" }]);
|
||||
}
|
||||
expect(references("toolu_early").flat()).toEqual([]);
|
||||
expect(references("toolu_bash").flat()).toEqual([]);
|
||||
// The announcement comes before the first reference, as the RFD requires.
|
||||
const index = (matches: (update: v2.SessionUpdate) => boolean) =>
|
||||
client.sessionUpdates.findIndex(({ update }) => matches(update));
|
||||
expect(
|
||||
index(
|
||||
(update) => update.sessionUpdate === "subagent_update" && update.sessionId === "agent-1",
|
||||
),
|
||||
).toBeLessThan(
|
||||
index(
|
||||
(update) =>
|
||||
update.sessionUpdate === "tool_call_update" &&
|
||||
(update as { toolCallId: string }).toolCallId === "toolu_stop",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["its task_started", "the resumedAgentId of its result alone"])(
|
||||
"restores a subagent that a SendMessage call resumes from the session's subagent history, at %s",
|
||||
async (signal) => {
|
||||
|
||||
@@ -2505,6 +2505,53 @@ describe("ACP v2 core", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("reports a failed API request that the turn goes on past where it failed", async () => {
|
||||
const text = "API Error: Connection lost mid-response. The response above may be incomplete.";
|
||||
const client = v2Client();
|
||||
await client.connect(async (agent) => {
|
||||
const { claude } = await openSession(agent);
|
||||
claude.send(cycleStart());
|
||||
// Claude Code keeps the tool call of the response that broke off, and runs it.
|
||||
claude.send(
|
||||
assistant("msg_partial", [
|
||||
{ type: "tool_use", id: "toolu_kept", name: "Bash", input: { command: "ls" } },
|
||||
]),
|
||||
);
|
||||
claude.send({
|
||||
...assistant("msg_api_error", [{ type: "text", text }]),
|
||||
message: {
|
||||
id: "msg_api_error",
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
model: "<synthetic>",
|
||||
content: [{ type: "text", text }],
|
||||
usage: {},
|
||||
},
|
||||
error: "server_error",
|
||||
});
|
||||
claude.send({
|
||||
type: "user",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [{ type: "tool_result", tool_use_id: "toolu_kept", content: "src" }],
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
uuid: randomUUID(),
|
||||
});
|
||||
claude.send(assistant("msg_after", [{ type: "text", text: "Done." }]));
|
||||
claude.send(result({ result: "Done." }));
|
||||
await vi.waitFor(() => expect(client.states()).toEqual(["running", "idle"]));
|
||||
});
|
||||
|
||||
const notice = { sessionUpdate: "notice", severity: "error", title: text };
|
||||
const shown = client.sessionUpdates.filter(
|
||||
(update) =>
|
||||
update.sessionUpdate === "notice" || update.sessionUpdate === "agent_message_chunk",
|
||||
);
|
||||
expect(shown).toMatchObject([notice, { sessionUpdate: "agent_message_chunk" }]);
|
||||
expect(shown[0]).toEqual(notice);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"an error at the turn limit",
|
||||
|
||||
@@ -435,6 +435,7 @@ import {
|
||||
stripLocalCommandMetadata,
|
||||
} from "../message-filters.js";
|
||||
import type { NewSessionMeta, SDKMessageFilter } from "../new-session-meta.js";
|
||||
import { computeSessionFingerprint } from "./session-fingerprint.js";
|
||||
import { promptToClaude, SUPPORTED_PROMPT_CONTENT_TYPES } from "../prompt-to-claude.js";
|
||||
import { AUTONOMOUS_RESULT_ORIGINS, isAbortedResult } from "../result-end.js";
|
||||
import {
|
||||
@@ -1065,12 +1066,13 @@ export type Session = {
|
||||
* independent of user ownership: opted-in clients also apply automatic
|
||||
* recommendations and settings-derived values. */
|
||||
appliedEffortLevel?: string;
|
||||
/** Why the SDK currently can't serve Fast mode, when the reason is one worth
|
||||
* telling the user about (see {@link FAST_MODE_UNAVAILABLE_EXPLANATIONS} —
|
||||
* routine states like the SDK's own opt-in requirement normalize to
|
||||
* `undefined`). Refreshed from every `fast_mode_disabled_reason` the SDK
|
||||
* reports on `system`/init and user-turn `result`s; surfaced in the Fast mode
|
||||
* option's description so a toggle that snaps back off explains itself. */
|
||||
/** Why the SDK currently can't serve Fast mode: the last
|
||||
* `fast_mode_disabled_reason` that `explainFastModeDisabledReason` explains.
|
||||
* {@link normalizeFastModeDisabledReason} drops the two it doesn't,
|
||||
* `sdk_opt_in_required` and `pending`, to `undefined`. Refreshed from every
|
||||
* `fast_mode_disabled_reason` the SDK reports on `system`/init and user-turn
|
||||
* `result`s; surfaced in the Fast mode option's description so a toggle that
|
||||
* snaps back off explains itself. */
|
||||
fastModeDisabledReason?: FastModeDisabledReason;
|
||||
abortController: AbortController;
|
||||
/** Signal the consumer races `query.next()` against. Aborted by cancel()
|
||||
@@ -1462,149 +1464,6 @@ function disarmForceCancel(session: Session): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Normalize skills without changing their SDK semantics. Array order and
|
||||
* duplicates do not affect the selected skill set, so neither should rebuild
|
||||
* the underlying Query process. */
|
||||
function normalizeSkills(skills: Options["skills"]): Options["skills"] {
|
||||
return Array.isArray(skills) ? [...new Set(skills)].sort() : skills;
|
||||
}
|
||||
|
||||
/** Whether a changed `_meta.claudeCode.options` key on session/load or
|
||||
* session/resume must rebuild the Query process. Almost every option is read
|
||||
* only when the process starts, so the default is `true`. Every key of the
|
||||
* SDK's `Options` must be classified: an SDK bump that adds or removes an
|
||||
* option fails to compile until it is decided here. */
|
||||
const OPTION_REBUILDS_SESSION = {
|
||||
additionalDirectories: true,
|
||||
agentProgressSummaries: true,
|
||||
agents: true,
|
||||
allowDangerouslySkipPermissions: true,
|
||||
allowedTools: true,
|
||||
betas: true,
|
||||
debug: true,
|
||||
debugFile: true,
|
||||
disallowedTools: true,
|
||||
effort: true,
|
||||
enableFileCheckpointing: true,
|
||||
env: true,
|
||||
executableArgs: true,
|
||||
extraArgs: true,
|
||||
fallbackModel: true,
|
||||
forwardSubagentText: true,
|
||||
includeHookEvents: true,
|
||||
managedSettings: true,
|
||||
maxBudgetUsd: true,
|
||||
maxThinkingTokens: true,
|
||||
maxTurns: true,
|
||||
mcpServers: true,
|
||||
model: true,
|
||||
outputFormat: true,
|
||||
pathToClaudeCodeExecutable: true,
|
||||
permissionPrompts: true,
|
||||
permissionPromptToolName: true,
|
||||
persistSession: true,
|
||||
perTaskStopAffordance: true,
|
||||
planModeInstructions: true,
|
||||
pluginDelivery: true,
|
||||
plugins: true,
|
||||
projectConfigRoot: true,
|
||||
promptSuggestions: true,
|
||||
sandbox: true,
|
||||
sessionStoreFlush: true,
|
||||
settings: true,
|
||||
settingSources: true,
|
||||
skills: true,
|
||||
strictMcpConfig: true,
|
||||
supportedDialogKinds: true,
|
||||
systemPrompt: true,
|
||||
taskBudget: true,
|
||||
thinking: true,
|
||||
toolAliases: true,
|
||||
toolConfig: true,
|
||||
tools: true,
|
||||
verbatimPrompts: true,
|
||||
// Managed or ignored by the adapter.
|
||||
agent: false,
|
||||
cwd: false,
|
||||
executable: false,
|
||||
includePartialMessages: false,
|
||||
permissionMode: false,
|
||||
// Per-call controls for attaching to the stored conversation.
|
||||
continue: false,
|
||||
forkSession: false,
|
||||
resume: false,
|
||||
resumeDropsTurn: false,
|
||||
resumeSessionAt: false,
|
||||
sessionId: false,
|
||||
// Callbacks and live objects: they cannot cross JSON-RPC and have no stable serialized form.
|
||||
abortController: false,
|
||||
canUseTool: false,
|
||||
hooks: false,
|
||||
onElicitation: false,
|
||||
onUserDialog: false,
|
||||
sessionStore: false,
|
||||
spawnClaudeCodeProcess: false,
|
||||
stderr: false,
|
||||
// No effect on an already-created session.
|
||||
loadTimeoutMs: false,
|
||||
title: false,
|
||||
} as const satisfies Record<keyof Options, boolean>;
|
||||
|
||||
/** A JSON value with object keys sorted, so key order never changes a
|
||||
* fingerprint. Functions and class instances (hook callbacks, abort
|
||||
* controllers, in-process MCP servers) cannot cross JSON-RPC and have no
|
||||
* stable serialized form, so they are left out. */
|
||||
function canonicalJson(value: unknown): unknown {
|
||||
if (typeof value === "function") return undefined;
|
||||
if (value === null || typeof value !== "object") return value;
|
||||
if (Array.isArray(value)) return value.map(canonicalJson);
|
||||
const proto = Object.getPrototypeOf(value);
|
||||
if (proto !== Object.prototype && proto !== null) return undefined;
|
||||
return Object.fromEntries(
|
||||
Object.keys(value)
|
||||
.sort()
|
||||
.map((key) => [key, canonicalJson((value as Record<string, unknown>)[key])])
|
||||
.filter(([, entry]) => entry !== undefined),
|
||||
);
|
||||
}
|
||||
|
||||
/** Compute a stable fingerprint of the session-defining params so we can
|
||||
* detect when a loadSession/resumeSession call requires tearing down and
|
||||
* recreating the underlying Query process. Every option classified in
|
||||
* {@link OPTION_REBUILDS_SESSION} is covered, so a warm resume never silently
|
||||
* keeps stale values. MCP servers are sorted by name, and skills are
|
||||
* normalized as a set, so ordering differences don't trigger unnecessary
|
||||
* recreations. */
|
||||
export function computeSessionFingerprint(params: {
|
||||
cwd: string;
|
||||
mcpServers?: NewSessionRequest["mcpServers"];
|
||||
additionalDirectories?: NewSessionRequest["additionalDirectories"];
|
||||
systemPrompt?: NewSessionRequest["systemPrompt"];
|
||||
_meta?: NewSessionRequest["_meta"];
|
||||
}): string {
|
||||
const servers = [...(params.mcpServers ?? [])].sort((a, b) => a.name.localeCompare(b.name));
|
||||
const meta = params._meta as (NewSessionMeta & Record<string, unknown>) | undefined;
|
||||
const options: Record<string, unknown> = Object.fromEntries(
|
||||
Object.entries(meta?.claudeCode?.options ?? {}).filter(
|
||||
([key]) => (OPTION_REBUILDS_SESSION as Record<string, boolean>)[key] === true,
|
||||
),
|
||||
);
|
||||
options.skills = normalizeSkills(options.skills as Options["skills"]);
|
||||
return JSON.stringify(
|
||||
canonicalJson({
|
||||
cwd: params.cwd,
|
||||
mcpServers: servers,
|
||||
additionalDirectories: params.additionalDirectories,
|
||||
additionalRoots: meta?.additionalRoots,
|
||||
systemPrompt: meta?.systemPrompt,
|
||||
clientSystemPrompt: params.systemPrompt ?? undefined,
|
||||
disableBuiltInTools: meta?.disableBuiltInTools,
|
||||
emitRawSDKMessages: meta?.claudeCode?.emitRawSDKMessages,
|
||||
options,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** The JSON-RPC code ACP assigns to `authRequired`. Read from the SDK so it
|
||||
* cannot drift from the errors this agent throws. */
|
||||
const AUTH_REQUIRED_CODE = RequestError.authRequired().code;
|
||||
@@ -3459,11 +3318,13 @@ export class ClaudeAcpAgent {
|
||||
nativeState: "completed" | "failed" | "cancelled",
|
||||
asyncState: "failed" | "stopped",
|
||||
context: string,
|
||||
// Whether Claude Code can keep the subagents running (see `finishAll`).
|
||||
{ mayStillRun = false }: { mayStillRun?: boolean } = {},
|
||||
): Promise<void> => {
|
||||
await compaction.interrupt();
|
||||
await Promise.all([
|
||||
subagents
|
||||
.finishAll(nativeState, sendUpdate)
|
||||
.finishAll(nativeState, sendUpdate, { mayStillRun })
|
||||
.catch((error) =>
|
||||
this.logger.error(
|
||||
`Session ${params.sessionId}: failed to publish terminal subagent state ${context}`,
|
||||
@@ -7118,7 +6979,9 @@ export class ClaudeAcpAgent {
|
||||
// and task store are independent of the previous transcript.
|
||||
// Clear both the in-memory snapshot and the client's visible plan
|
||||
// before any follow-up prompt can republish stale tasks.
|
||||
await finishLifecycle("failed", "failed", "during conversation reset");
|
||||
await finishLifecycle("failed", "failed", "during conversation reset", {
|
||||
mayStillRun: true,
|
||||
});
|
||||
subagents.clear();
|
||||
asyncTasks.clear();
|
||||
session.resolvedToolNames?.clear();
|
||||
@@ -7985,7 +7848,8 @@ export class ClaudeAcpAgent {
|
||||
// A subagent's text replays onto the root only for a client that gets it
|
||||
// there live: one without native subagent sessions that asked for the
|
||||
// flattened transcript. With native sessions the text goes to the child
|
||||
// session, which AIR's replay restores and the RFD form leaves out for now.
|
||||
// session, which the replay restores in both forms: AIR's draft under a
|
||||
// replay id, the RFD form under the session id the child had live.
|
||||
const forwardSubagentText =
|
||||
!clientSupportsSubagents(this.clientCapabilities) &&
|
||||
(session?.forwardSubagentText ?? supportsSubagentTranscript(this.clientCapabilities));
|
||||
@@ -8405,7 +8269,12 @@ export class ClaudeAcpAgent {
|
||||
continue;
|
||||
}
|
||||
if (rfdReplay && (await rfdReplay.toolCallUpdate(notification.update, child))) continue;
|
||||
await this.client.sessionUpdate({ ...notification, sessionId: replayTargetSessionId });
|
||||
const replayed = { ...notification, sessionId: replayTargetSessionId };
|
||||
await this.client.sessionUpdate(
|
||||
rfdReplay
|
||||
? asSdkSessionNotification(rfdReplay.withTarget(replayed, replayTargetSessionId))
|
||||
: replayed,
|
||||
);
|
||||
}
|
||||
|
||||
// The history of each subagent is in its own transcript. The replay
|
||||
@@ -9352,6 +9221,17 @@ export class ClaudeAcpAgent {
|
||||
* The work that follows a session setup response: the session's commands,
|
||||
* the current states of the subagents a load replayed, and MCP server
|
||||
* authentication.
|
||||
*
|
||||
* Call it in the same synchronous step as the handler's `return`: an
|
||||
* `await` between them would let the timer run before the response. The ACP
|
||||
* SDK (2.6.0) gives a request handler no responder and no after-response
|
||||
* hook, so the order rests on two things that its source shows and its API
|
||||
* does not promise. `registerAppRequest` queues the response in the
|
||||
* microtask that follows the handler's return, with no macrotask in
|
||||
* between, so the timer runs after it. And `Connection.sendMessage` writes
|
||||
* in call order, so what the timer sends is written after the response. A
|
||||
* caller that does not serve the agent through the SDK's `AgentApp` (see
|
||||
* `v1AgentApp`) has no such order.
|
||||
*/
|
||||
private afterSetupResponse(sessionId: string, mcpServers: McpServer[]): void {
|
||||
setTimeout(() => {
|
||||
@@ -10452,7 +10332,8 @@ export class ClaudeAcpAgent {
|
||||
// `fast_mode_disabled_reason` reflects the post-switch model since SDK
|
||||
// 0.3.219 (the initialize response used to answer from the spawn-time
|
||||
// model). A fresh SDK session reports `sdk_opt_in_required` — the toggle IS
|
||||
// the opt-in — which normalizes away, so only real blockers are retained.
|
||||
// the opt-in — which normalizes to `undefined`, as does `pending`; every
|
||||
// other reason is retained.
|
||||
const fastModeDisabledReason = fastModeEnabled
|
||||
? undefined
|
||||
: normalizeFastModeDisabledReason(initializationResult.fast_mode_disabled_reason);
|
||||
|
||||
147
src/v1/session-fingerprint.ts
Normal file
147
src/v1/session-fingerprint.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import type { Options } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { NewSessionRequest } from "@agentclientprotocol/sdk/unstable";
|
||||
|
||||
import type { NewSessionMeta } from "../new-session-meta.js";
|
||||
|
||||
/** Normalize skills without changing their SDK semantics. Array order and
|
||||
* duplicates do not affect the selected skill set, so neither should rebuild
|
||||
* the underlying Query process. */
|
||||
function normalizeSkills(skills: Options["skills"]): Options["skills"] {
|
||||
return Array.isArray(skills) ? [...new Set(skills)].sort() : skills;
|
||||
}
|
||||
|
||||
/** Whether a changed `_meta.claudeCode.options` key on session/load or
|
||||
* session/resume must rebuild the Query process. Almost every option is read
|
||||
* only when the process starts, so the default is `true`. Every key of the
|
||||
* SDK's `Options` must be classified: an SDK bump that adds or removes an
|
||||
* option fails to compile until it is decided here. */
|
||||
const OPTION_REBUILDS_SESSION = {
|
||||
additionalDirectories: true,
|
||||
agentProgressSummaries: true,
|
||||
agents: true,
|
||||
allowDangerouslySkipPermissions: true,
|
||||
allowedTools: true,
|
||||
betas: true,
|
||||
debug: true,
|
||||
debugFile: true,
|
||||
disallowedTools: true,
|
||||
effort: true,
|
||||
enableFileCheckpointing: true,
|
||||
env: true,
|
||||
executableArgs: true,
|
||||
extraArgs: true,
|
||||
fallbackModel: true,
|
||||
forwardSubagentText: true,
|
||||
includeHookEvents: true,
|
||||
managedSettings: true,
|
||||
maxBudgetUsd: true,
|
||||
maxThinkingTokens: true,
|
||||
maxTurns: true,
|
||||
mcpServers: true,
|
||||
model: true,
|
||||
outputFormat: true,
|
||||
pathToClaudeCodeExecutable: true,
|
||||
permissionPrompts: true,
|
||||
permissionPromptToolName: true,
|
||||
persistSession: true,
|
||||
perTaskStopAffordance: true,
|
||||
planModeInstructions: true,
|
||||
pluginDelivery: true,
|
||||
plugins: true,
|
||||
projectConfigRoot: true,
|
||||
promptSuggestions: true,
|
||||
sandbox: true,
|
||||
sessionStoreFlush: true,
|
||||
settings: true,
|
||||
settingSources: true,
|
||||
skills: true,
|
||||
strictMcpConfig: true,
|
||||
supportedDialogKinds: true,
|
||||
systemPrompt: true,
|
||||
taskBudget: true,
|
||||
thinking: true,
|
||||
toolAliases: true,
|
||||
toolConfig: true,
|
||||
tools: true,
|
||||
verbatimPrompts: true,
|
||||
// Managed or ignored by the adapter.
|
||||
agent: false,
|
||||
cwd: false,
|
||||
executable: false,
|
||||
includePartialMessages: false,
|
||||
permissionMode: false,
|
||||
// Per-call controls for attaching to the stored conversation.
|
||||
continue: false,
|
||||
forkSession: false,
|
||||
resume: false,
|
||||
resumeDropsTurn: false,
|
||||
resumeSessionAt: false,
|
||||
sessionId: false,
|
||||
// Callbacks and live objects: they cannot cross JSON-RPC and have no stable serialized form.
|
||||
abortController: false,
|
||||
canUseTool: false,
|
||||
hooks: false,
|
||||
onElicitation: false,
|
||||
onUserDialog: false,
|
||||
sessionStore: false,
|
||||
spawnClaudeCodeProcess: false,
|
||||
stderr: false,
|
||||
// No effect on an already-created session.
|
||||
loadTimeoutMs: false,
|
||||
title: false,
|
||||
} as const satisfies Record<keyof Options, boolean>;
|
||||
|
||||
/** A JSON value with object keys sorted, so key order never changes a
|
||||
* fingerprint. Functions and class instances (hook callbacks, abort
|
||||
* controllers, in-process MCP servers) cannot cross JSON-RPC and have no
|
||||
* stable serialized form, so they are left out. */
|
||||
function canonicalJson(value: unknown): unknown {
|
||||
if (typeof value === "function") return undefined;
|
||||
if (value === null || typeof value !== "object") return value;
|
||||
if (Array.isArray(value)) return value.map(canonicalJson);
|
||||
const proto = Object.getPrototypeOf(value);
|
||||
if (proto !== Object.prototype && proto !== null) return undefined;
|
||||
return Object.fromEntries(
|
||||
Object.keys(value)
|
||||
.sort()
|
||||
.map((key) => [key, canonicalJson((value as Record<string, unknown>)[key])])
|
||||
.filter(([, entry]) => entry !== undefined),
|
||||
);
|
||||
}
|
||||
|
||||
/** Compute a stable fingerprint of the session-defining params so we can
|
||||
* detect when a loadSession/resumeSession call requires tearing down and
|
||||
* recreating the underlying Query process. Every option classified in
|
||||
* {@link OPTION_REBUILDS_SESSION} is covered, so a warm resume never silently
|
||||
* keeps stale values. MCP servers are sorted by name, and skills are
|
||||
* normalized as a set, so ordering differences don't trigger unnecessary
|
||||
* recreations. */
|
||||
export function computeSessionFingerprint(params: {
|
||||
cwd: string;
|
||||
mcpServers?: NewSessionRequest["mcpServers"];
|
||||
additionalDirectories?: NewSessionRequest["additionalDirectories"];
|
||||
systemPrompt?: NewSessionRequest["systemPrompt"];
|
||||
_meta?: NewSessionRequest["_meta"];
|
||||
}): string {
|
||||
const servers = [...(params.mcpServers ?? [])].sort((a, b) => a.name.localeCompare(b.name));
|
||||
const meta = params._meta as (NewSessionMeta & Record<string, unknown>) | undefined;
|
||||
const options: Record<string, unknown> = Object.fromEntries(
|
||||
Object.entries(meta?.claudeCode?.options ?? {}).filter(
|
||||
([key]) => (OPTION_REBUILDS_SESSION as Record<string, boolean>)[key] === true,
|
||||
),
|
||||
);
|
||||
options.skills = normalizeSkills(options.skills as Options["skills"]);
|
||||
return JSON.stringify(
|
||||
canonicalJson({
|
||||
cwd: params.cwd,
|
||||
mcpServers: servers,
|
||||
additionalDirectories: params.additionalDirectories,
|
||||
additionalRoots: meta?.additionalRoots,
|
||||
systemPrompt: meta?.systemPrompt,
|
||||
clientSystemPrompt: params.systemPrompt ?? undefined,
|
||||
disableBuiltInTools: meta?.disableBuiltInTools,
|
||||
emitRawSDKMessages: meta?.claudeCode?.emitRawSDKMessages,
|
||||
options,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -448,10 +448,16 @@ class V2Agent implements AgentHandle, SessionConnection {
|
||||
const response = await this.setupFields(session);
|
||||
this.afterSetup(session, params);
|
||||
// The response ends the replay, and the replayed children's states before
|
||||
// it are history: their current states follow it. Nothing enforces that
|
||||
// the timer runs after the response goes out; it relies on the response
|
||||
// being written when this handler returns, as the v1 core's
|
||||
// `afterSetupResponse` does.
|
||||
// it are history: their current states follow it. The ACP SDK (2.6.0)
|
||||
// gives a request handler no responder and no after-response hook, so the
|
||||
// order rests on two things that its source shows and its API does not
|
||||
// promise. `registerAppRequest` queues the response in the microtask that
|
||||
// follows this handler's return, with no macrotask in between, so the
|
||||
// timer runs after it. And `Connection.sendMessage` writes in call order,
|
||||
// so what the timer sends is written after the response. The timer is
|
||||
// set in the same synchronous step as the `return`: an `await` between
|
||||
// them would let it run first. The v1 core's `afterSetupResponse` relies
|
||||
// on the same.
|
||||
if (subagents) setTimeout(() => subagents.afterResponse(), 0);
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -136,8 +136,9 @@ export class TurnReport {
|
||||
* - Claude Code's sign-in message. Its `/login` instruction is for Claude
|
||||
* Code's terminal, and the turn's idle fails with `authRequired` instead.
|
||||
* - The message that Claude Code wrote for a failed API request (`error` on
|
||||
* a `<synthetic>` message). The turn's idle carries it when the turn
|
||||
* fails, and a notice otherwise (see {@link takeApiError}).
|
||||
* a `<synthetic>` message). A notice carries it where the turn goes on
|
||||
* past it. At the turn's end, the idle carries it when the turn fails,
|
||||
* and a notice otherwise (see {@link takeApiError}).
|
||||
*/
|
||||
assistant(message: SDKAssistantMessage): boolean {
|
||||
if (message.error) this.errorKind = message.error;
|
||||
|
||||
@@ -903,6 +903,7 @@ export class V2Session implements RequestTarget {
|
||||
this.tasks.toolResults(this.output, message);
|
||||
return;
|
||||
}
|
||||
this.wentOnPastApiError();
|
||||
// The rewrite applies to the updates of this message only.
|
||||
this.output.rewrite = this.clearContext.toolResults(message);
|
||||
try {
|
||||
@@ -935,6 +936,7 @@ export class V2Session implements RequestTarget {
|
||||
if (this.stopped.drops(message)) return;
|
||||
if (message.parent_tool_use_id === null) {
|
||||
this.outputStarts();
|
||||
this.wentOnPastApiError();
|
||||
this.reports.streamEvent(message);
|
||||
}
|
||||
this.output.streamEvent(message);
|
||||
@@ -947,6 +949,7 @@ export class V2Session implements RequestTarget {
|
||||
// request are not shown as Claude's text (see `TurnReport`).
|
||||
if (this.turnReport.assistant(message)) return;
|
||||
this.outputStarts();
|
||||
this.wentOnPastApiError();
|
||||
this.reports.assistant(message);
|
||||
}
|
||||
this.output.assistant(message);
|
||||
@@ -1208,10 +1211,21 @@ export class V2Session implements RequestTarget {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the text of a failed API request that Claude Code wrote into the
|
||||
* conversation (see `TurnReport.assistant`) as a notice. A turn whose idle
|
||||
* fails sends none: its error carries the failure. `end` is the turn's
|
||||
* result, if it has one.
|
||||
* The turn went on past a failed API request: Claude Code kept the tool
|
||||
* calls of a response that broke off ("Connection lost mid-response. The
|
||||
* response above may be incomplete.") and ran them. Its notice goes out
|
||||
* here, after the output it is about, and not at the turn's end.
|
||||
*/
|
||||
private wentOnPastApiError(): void {
|
||||
const text = this.turnReport.takeApiError();
|
||||
if (text) this.reports.apiError(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* At the turn's end, sends the text of a failed API request that Claude
|
||||
* Code wrote into the conversation (see `TurnReport.assistant`) as a
|
||||
* notice. A turn whose idle fails sends none: its error carries the
|
||||
* failure. `end` is the turn's result, if it has one.
|
||||
*/
|
||||
private reportApiError(end: TurnEnd | undefined): void {
|
||||
const text = this.turnReport.takeApiError();
|
||||
|
||||
@@ -259,7 +259,7 @@ export class SubagentReplay {
|
||||
if (sendMessage) {
|
||||
if (sendMessage.recipientSessionId === undefined) {
|
||||
sendMessage.shownAsToolCall = true;
|
||||
return [notification];
|
||||
return [this.transcript.withTarget(notification, output.sessionId)];
|
||||
}
|
||||
if (sendMessage.outgoingSent) return [];
|
||||
sendMessage.outgoingSent = true;
|
||||
@@ -288,7 +288,7 @@ export class SubagentReplay {
|
||||
return [];
|
||||
}
|
||||
const child = this.transcript.children.get(update.toolCallId);
|
||||
if (!child) return [notification];
|
||||
if (!child) return [this.transcript.withTarget(notification, output.sessionId)];
|
||||
if (
|
||||
update.status === "completed" &&
|
||||
child.announced &&
|
||||
|
||||
107
src/v2/tasks.ts
107
src/v2/tasks.ts
@@ -70,7 +70,9 @@ import {
|
||||
rfdLabels,
|
||||
sendMessageResume,
|
||||
subagentIdentity,
|
||||
targetAgentId,
|
||||
unexposedControlFallback,
|
||||
withSessionReference,
|
||||
type HistoricalSubagent,
|
||||
type SubagentIdentity,
|
||||
type SubagentModels,
|
||||
@@ -133,7 +135,7 @@ export interface RequestTarget {
|
||||
openRequest<T>(signal: AbortSignal, send: (signal: AbortSignal) => Promise<T>): Promise<T>;
|
||||
}
|
||||
|
||||
type ChildState = "idle" | "running" | "requires_action";
|
||||
type ChildState = "idle" | "running" | "requires_action" | "unknown";
|
||||
|
||||
type IdleOutcome =
|
||||
| { stopReason: "end_turn" }
|
||||
@@ -178,6 +180,11 @@ type Child = {
|
||||
description: string | undefined;
|
||||
/** Whether a run of it is going on: its foreground work. */
|
||||
working: boolean;
|
||||
/**
|
||||
* Whether the adapter stopped following it while a run of it went on (see
|
||||
* `conversationReset`): its state is `unknown`.
|
||||
*/
|
||||
unobservable: boolean;
|
||||
/** The state its stream was told last. */
|
||||
reportedState: ChildState | undefined;
|
||||
/** How its last run ended. */
|
||||
@@ -316,6 +323,11 @@ export class SessionTasks {
|
||||
string,
|
||||
{ initial: AcpSessionNotification; message?: string; delegationId?: string }
|
||||
>();
|
||||
/**
|
||||
* The agent that each TaskStop or SendMessage call names (`targetAgentId`),
|
||||
* until the call's final update.
|
||||
*/
|
||||
private readonly targetByToolCall = new Map<string, string>();
|
||||
/** The restore of a subagent from the session's history, while it reads it (see {@link restore}). */
|
||||
private restoring: Promise<void> | undefined;
|
||||
|
||||
@@ -831,18 +843,24 @@ export class SessionTasks {
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude Code mounted a fresh conversation (`conversation_reset`). Its
|
||||
* subagents end, deeper ones first, as on v1 (`finishAll`): each
|
||||
* child that works goes `idle` with no stop reason, since nothing says how
|
||||
* its run ended, after its unfinished tool calls end `failed`. Then every
|
||||
* child is forgotten: a later event of one
|
||||
* changes nothing, and a child not announced yet stays unexposed. Claude
|
||||
* Code's tasks (the live subagents, background commands and other tasks)
|
||||
* stay as its task events report them.
|
||||
* Claude Code mounted a fresh conversation (`conversation_reset`). Every
|
||||
* child is forgotten: a later event of one changes nothing, and a child not
|
||||
* announced yet stays unexposed. Claude Code's tasks (the live subagents,
|
||||
* background commands and other tasks) stay as its task events report them,
|
||||
* so a child that works may go on working, unobserved. As on v1
|
||||
* (`finishAll`), each such child, deeper ones first, goes `unknown`, as the
|
||||
* RFD requires when a child can no longer be observed, on its stream and in
|
||||
* its parent's mirror, and loses its `cancel` capability: a forgotten child
|
||||
* cannot be stopped. Nothing says that its run or its tool calls ended, so
|
||||
* its unfinished tool calls keep their status. A child that is idle keeps
|
||||
* its state.
|
||||
*/
|
||||
conversationReset(): void {
|
||||
for (const child of [...this.parentsFirst()].reverse()) {
|
||||
if (child.working) this.endRun(child, undefined);
|
||||
if (!child.working) continue;
|
||||
child.working = false;
|
||||
child.unobservable = true;
|
||||
this.sendChildState(child, "unknown", { capabilities: null });
|
||||
}
|
||||
this.publish();
|
||||
for (const child of this.children.values()) this.configs.forget(child.sessionId);
|
||||
@@ -857,6 +875,7 @@ export class SessionTasks {
|
||||
this.hiddenSendMessages.clear();
|
||||
this.shownSendMessages.clear();
|
||||
this.handbacks.clear();
|
||||
this.targetByToolCall.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1137,6 +1156,7 @@ export class SessionTasks {
|
||||
title: undefined,
|
||||
description: undefined,
|
||||
working: true,
|
||||
unobservable: false,
|
||||
reportedState: undefined,
|
||||
outcome: undefined,
|
||||
openRequests: 0,
|
||||
@@ -1373,12 +1393,48 @@ export class SessionTasks {
|
||||
* Rewrites an update of `output`, the output of the root or of `child`: an
|
||||
* update of an Agent or Task call goes out as {@link launchUpdate} says,
|
||||
* and Claude Code's late result of a call that the child's stop ended
|
||||
* goes nowhere.
|
||||
* goes nowhere. A tool call that the client is shown and that operates on
|
||||
* an announced child of another session gets the RFD's `session` reference
|
||||
* (see {@link withTarget}).
|
||||
*/
|
||||
private route(
|
||||
output: SessionOutput,
|
||||
notification: AcpSessionNotification,
|
||||
child: Child | undefined,
|
||||
): AcpSessionNotification[] {
|
||||
return this.routed(output, notification, child).map((shown) =>
|
||||
this.withTarget(shown, output.sessionId),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `notification`, an update that the client is shown on the session
|
||||
* `shownOn`, with a `session` reference when it is of a TaskStop or
|
||||
* SendMessage call that names an announced child by its agent id, or of a
|
||||
* SendMessage call that resumed one and stays shown (see
|
||||
* {@link requestShows}), as on v1 (`NativeSubagentRuntime.withTarget`).
|
||||
*/
|
||||
private withTarget(
|
||||
notification: AcpSessionNotification,
|
||||
shownOn: string,
|
||||
): AcpSessionNotification {
|
||||
const { update } = notification;
|
||||
if (update.sessionUpdate !== "tool_call" && update.sessionUpdate !== "tool_call_update") {
|
||||
return notification;
|
||||
}
|
||||
const agentId = this.targetByToolCall.get(update.toolCallId);
|
||||
if (FINAL_STATUSES.has(update.status)) this.targetByToolCall.delete(update.toolCallId);
|
||||
const child =
|
||||
this.childByToolUse.get(update.toolCallId) ??
|
||||
(agentId !== undefined ? this.children.get(agentId) : undefined);
|
||||
if (!child?.announced || child.sessionId === shownOn) return notification;
|
||||
return withSessionReference(notification, child.sessionId);
|
||||
}
|
||||
|
||||
private routed(
|
||||
output: SessionOutput,
|
||||
notification: AcpSessionNotification,
|
||||
child: Child | undefined,
|
||||
): AcpSessionNotification[] {
|
||||
const { update } = notification;
|
||||
if (update.sessionUpdate !== "tool_call" && update.sessionUpdate !== "tool_call_update") {
|
||||
@@ -1386,6 +1442,8 @@ export class SessionTasks {
|
||||
}
|
||||
if (child?.stopped.has(update.toolCallId)) return [];
|
||||
const toolName = (update._meta?.claudeCode as { toolName?: unknown } | undefined)?.toolName;
|
||||
const agentId = targetAgentId(toolName, update.rawInput);
|
||||
if (agentId !== undefined) this.targetByToolCall.set(update.toolCallId, agentId);
|
||||
if (toolName === "SendMessage") this.senderByToolUse.set(update.toolCallId, output.sessionId);
|
||||
const message =
|
||||
this.sendMessageUpdate(output, notification, toolName) ??
|
||||
@@ -1594,16 +1652,15 @@ export class SessionTasks {
|
||||
/**
|
||||
* A run of `child` ended: its unfinished tool calls get their final status,
|
||||
* except its background work, then its idle goes out, as v2 asks. A stopped
|
||||
* run's end `cancelled`, and a failed run's or an unexplained end's
|
||||
* (`undefined`, an idle with no stop reason) `failed`. At a completed end, a
|
||||
* run's end `cancelled`, and a failed run's `failed`. At a completed end, a
|
||||
* tool call that Claude dropped before it ran ends `cancelled`, as on the
|
||||
* root (see `SessionOutput.closeUnfinished`).
|
||||
*/
|
||||
private endRun(child: Child, outcome: IdleOutcome | undefined): void {
|
||||
private endRun(child: Child, outcome: IdleOutcome): void {
|
||||
if (!child.working) {
|
||||
// A later report of the same failure can give its error text.
|
||||
if (
|
||||
outcome?.stopReason === "error" &&
|
||||
outcome.stopReason === "error" &&
|
||||
outcome.error &&
|
||||
child.outcome?.stopReason === "error" &&
|
||||
!child.outcome.error
|
||||
@@ -1614,10 +1671,10 @@ export class SessionTasks {
|
||||
return;
|
||||
}
|
||||
const keep = this.backgroundWork(child.sessionId);
|
||||
if (outcome?.stopReason === "end_turn") {
|
||||
if (outcome.stopReason === "end_turn") {
|
||||
child.output!.closeUnfinished(false, keep);
|
||||
} else {
|
||||
const status = outcome?.stopReason === "cancelled" ? "cancelled" : "failed";
|
||||
const status = outcome.stopReason === "cancelled" ? "cancelled" : "failed";
|
||||
const ended = child.output!.finishUnfinished(status, keep, { exitTerminals: true });
|
||||
for (const toolCallId of ended) child.stopped.add(toolCallId);
|
||||
}
|
||||
@@ -1659,6 +1716,7 @@ export class SessionTasks {
|
||||
}
|
||||
|
||||
private childState(child: Child): ChildState {
|
||||
if (child.unobservable) return "unknown";
|
||||
if (!child.working) return "idle";
|
||||
return child.openRequests > 0 ? "requires_action" : "running";
|
||||
}
|
||||
@@ -1679,12 +1737,21 @@ export class SessionTasks {
|
||||
this.sendChildState(child, state);
|
||||
}
|
||||
|
||||
private sendChildState(child: Child, state: ChildState): void {
|
||||
private sendChildState(
|
||||
child: Child,
|
||||
state: ChildState,
|
||||
capabilities?: { capabilities: null },
|
||||
): void {
|
||||
child.reportedState = state;
|
||||
const snapshot = stateObject(state, state === "idle" ? child.outcome : undefined);
|
||||
this.host.send({
|
||||
sessionId: child.parentSessionId,
|
||||
update: { sessionUpdate: "subagent_update", sessionId: child.sessionId, state: snapshot },
|
||||
update: {
|
||||
sessionUpdate: "subagent_update",
|
||||
sessionId: child.sessionId,
|
||||
...capabilities,
|
||||
state: snapshot,
|
||||
},
|
||||
});
|
||||
this.host.send({
|
||||
sessionId: child.sessionId,
|
||||
@@ -2044,6 +2111,8 @@ function stateObject(state: ChildState, outcome: IdleOutcome | undefined): v2.St
|
||||
return { state: "running" };
|
||||
case "requires_action":
|
||||
return { state: "requires_action" };
|
||||
case "unknown":
|
||||
return { state: "unknown" };
|
||||
default:
|
||||
return { state: "idle", ...(outcome ?? {}) } as v2.StateUpdate;
|
||||
}
|
||||
|
||||
@@ -85,6 +85,9 @@ function v2ToolCallContent(item: ToolCallContent): v2.ToolCallContent {
|
||||
return item;
|
||||
case "terminal":
|
||||
return item;
|
||||
case "session":
|
||||
// A reference to a session, such as a subagent's, is the same in v2.
|
||||
return item;
|
||||
default:
|
||||
if (isV2Diff(item)) return item;
|
||||
// A v1 diff names one path, with old and new texts that can be a
|
||||
|
||||
Reference in New Issue
Block a user