mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 04:30:59 +00:00
feat: report the model of each native subagent (#1432)
## Summary A native subagent child session gets its model only from what the child reports. The update is a `config_option_update` with the child's session id and a single `model` select option. This follows the subagents RFD, [Child configuration](https://github.com/agentclientprotocol/agent-client-protocol/blob/main/docs/rfds/subagents.mdx#child-configuration) (see also agentclientprotocol/agent-client-protocol#2482). ## Relation to #1417 #1417 reports a child's model from its responses, in the `rfd` form only, with the raw model id as the name. This PR keeps that source and extends it: - The `air` form gets the same update. - History replay sends each child's model from its transcript. - The value maps to an entry of the root session's `model` option, so a client can compare the child's model with the session's. There is no model at spawn. The adapter cannot tell the model that Claude Code picks for a new child, for example with `CLAUDE_CODE_SUBAGENT_MODEL_FORCE`, or when an alias runs on the parent's exact model. So the first update comes with the child's first real response (`message.model`, not `<synthetic>`). A new update goes only when the entry changes. The configuration hooks of #1417 (`PostModelSwitch` and the others) now also run for `air` clients. A hook names the SDK agent id, so in the `air` form its options go to the agent's current generation session (`<agent id>:generation:<n>`). In the `air` form, no configuration goes after a child's end. ## Notification ```json { "sessionId": "<child session id>", "update": { "sessionUpdate": "config_option_update", "configOptions": [{ "id": "model", "name": "Model", "category": "model", "type": "select", "currentValue": "opus[1m]", "options": [{ "value": "opus[1m]", "name": "Opus 4.6 (1M context)" }] }] } } ``` - The client cannot change a child's configuration, so the select offers only the current value. - No field is added to `subagent_spawned` or `subagent_update`. ## Matching a model id to an entry Best first: 1. the root session's current entry, when it runs the same model up to a context hint, such as `opus[1m]` or `default` for `claude-opus-4-6`; 2. the listed entry whose value or resolved model is the id; 3. a listed entry that runs the same model up to a context hint. When several entries run the model, a concrete entry comes before `default`, unless `default` is the current entry. A model id is compared as it is, so a dated or provider id (`claude-haiku-4-5-20251001`, `eu.anthropic.claude-sonnet-5`) matches only the same id: `ModelInfo.resolvedModel` is the wire id that a response reports. A model that matches no entry goes as its full id, with its display name when the session's model list has one. A `PostModelSwitch` hook of a child uses the same matching. ## Replay - A child without responses in its transcript gets no model. - `rfd` form: the replay sends a child's model right after its announcement, from its first response, and again when a later response changes it. During a load, the session's model list can be unknown, so the replay can send the full id. After the load response, a child's model goes again only when the list maps it to another entry. - `air` form: the replay ends every child, and nothing may follow the end. So each child's latest response model goes once the session creation ended, just before the child's end, as the entry of the session's model list. ## Validation - `npm run build` - `npm run check` - `npm run test:run`, three runs: 3315 passed, 30 skipped. - `src/tests/acp-scenarios.test.ts`, 30 runs in 6 parallel workers: no failure. New tests cover: - no model before the child's first response, in both forms; - an update only when the entry changes; - no update to an ended `air` child, also with an update in flight at the end; - a hook's options under the current `air` generation; - matching for `opus[1m]` and `default`, concrete entries before `default`, listed entries, dated and provider ids, and unlisted ids; - the `PostModelSwitch` entry, in both forms; - an `air` load replay that maps a child's model only once the session exists; - `rfd` replay with an unlisted full id, and no duplicate update after the load response. The scenario snapshots for both forms and v2 are updated.
This commit is contained in:
101
src/acp-agent.ts
101
src/acp-agent.ts
@@ -116,14 +116,17 @@ import {
|
||||
type SubagentState,
|
||||
} from "./acp-subagents.js";
|
||||
import {
|
||||
childModelOption,
|
||||
isNativeSubagentControlTool,
|
||||
isNativeSubagentControlUpdate,
|
||||
NativeSubagent,
|
||||
NativeSubagentRuntime,
|
||||
nativeSubagentState,
|
||||
responseModelEntry,
|
||||
resumedNativeSubagentId,
|
||||
sendMessageResume,
|
||||
sendMessageResumePrompt,
|
||||
type SubagentModels,
|
||||
} from "./native-subagents.js";
|
||||
import {
|
||||
AIR_ASYNC_TASKS_CAPABILITY,
|
||||
@@ -203,6 +206,7 @@ import {
|
||||
import {
|
||||
type ReplayChild,
|
||||
RfdSubagentReplay,
|
||||
firstResponseModel,
|
||||
replaySubagentTerminalState,
|
||||
} from "./subagent-replay.js";
|
||||
import {
|
||||
@@ -338,6 +342,8 @@ import {
|
||||
buildModelConfigOption,
|
||||
getAvailableModels,
|
||||
MODEL_CONFIG_ID,
|
||||
modelDisplayName,
|
||||
modelEntryFor,
|
||||
narrowToGatewayModels,
|
||||
resolveModelPreference,
|
||||
type SessionModelState,
|
||||
@@ -980,6 +986,8 @@ type ReplayState = Pick<
|
||||
/** A replay that runs before its session record exists. */
|
||||
type PendingReplay = {
|
||||
state: ReplayState;
|
||||
/** Settles when the session creation ends: the session record and its model list exist, or `stopped` is set. */
|
||||
sessionReady?: Promise<void>;
|
||||
/** Set when the session creation fails. The replay then sends no more updates. */
|
||||
stopped: boolean;
|
||||
};
|
||||
@@ -3067,6 +3075,8 @@ export class ClaudeAcpAgent {
|
||||
},
|
||||
stopped: false,
|
||||
};
|
||||
let sessionCreated!: () => void;
|
||||
pending.sessionReady = new Promise((resolve) => (sessionCreated = resolve));
|
||||
const transcript = readResumedSession(params.sessionId, this.logger);
|
||||
const replay = transcript
|
||||
.then(({ messages }) => this.replaySessionHistory(params.sessionId, messages, pending))
|
||||
@@ -3092,9 +3102,11 @@ export class ClaudeAcpAgent {
|
||||
);
|
||||
} catch (error) {
|
||||
pending.stopped = true;
|
||||
sessionCreated();
|
||||
await replay;
|
||||
throw error;
|
||||
}
|
||||
sessionCreated();
|
||||
timing.phase("session-ready");
|
||||
const replayed = await replay;
|
||||
if (!replayed.ok) throw replayed.error;
|
||||
@@ -4549,6 +4561,7 @@ export class ClaudeAcpAgent {
|
||||
this.logger,
|
||||
(agentId) => subagentLineage(params.sessionId, agentId, this.logger),
|
||||
(launchId) => subagentLaunchedBy(params.sessionId, launchId),
|
||||
this.subagentModels(params.sessionId),
|
||||
));
|
||||
// Adapter-composed advisories (hook feedback, model fallbacks) are live
|
||||
// events, not something the model said. Clients on the notice contract get
|
||||
@@ -9284,8 +9297,11 @@ export class ClaudeAcpAgent {
|
||||
/** The state of the last persisted task notification of the child.
|
||||
* It comes after the launch tool_result, so it wins. */
|
||||
notifiedState?: SubagentState;
|
||||
/** The model id of the child's latest response in its transcript. */
|
||||
responseModel?: string;
|
||||
}
|
||||
>();
|
||||
const replayModels = this.subagentModels(sessionId);
|
||||
|
||||
// Registers the Agent and Task launches of `list`, and the terminal state
|
||||
// of each launch from its tool_result.
|
||||
@@ -9415,8 +9431,10 @@ export class ClaudeAcpAgent {
|
||||
: { agents: new Map() };
|
||||
const rfdReplay =
|
||||
form === "rfd"
|
||||
? new RfdSubagentReplay(rfdHistory, (notification) =>
|
||||
this.client.sessionUpdate(asSdkSessionNotification(notification)),
|
||||
? new RfdSubagentReplay(
|
||||
rfdHistory,
|
||||
(notification) => this.client.sessionUpdate(asSdkSessionNotification(notification)),
|
||||
replayModels,
|
||||
)
|
||||
: undefined;
|
||||
rfdReplay?.scan(messages, sessionId);
|
||||
@@ -9539,6 +9557,18 @@ export class ClaudeAcpAgent {
|
||||
: nativeReplayEnabled && parentToolUseId
|
||||
? await announceReplayChild(parentToolUseId)
|
||||
: sessionId;
|
||||
if (message.type === "assistant") {
|
||||
const model = (message.message as { model?: string }).model;
|
||||
if (rfdReplay && child) {
|
||||
await rfdReplay.responseModel(child, model);
|
||||
} else if (nativeReplayEnabled && parentToolUseId) {
|
||||
// AIR's draft: sent before the child's end, when the model list is known.
|
||||
const replayChild = replayChildren.get(parentToolUseId);
|
||||
if (replayChild && model && model !== "<synthetic>") {
|
||||
replayChild.responseModel = model;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (
|
||||
message.type === "assistant" &&
|
||||
parentToolUseId &&
|
||||
@@ -9669,13 +9699,13 @@ export class ClaudeAcpAgent {
|
||||
for (const toolUseId of subagentLaunchIds(content)) {
|
||||
const launched = rfdReplay.childOfLaunch(toolUseId);
|
||||
if (!launched || launched.announced) continue;
|
||||
await rfdReplay.announce(launched);
|
||||
const childMessages = await readSubagentMessages(
|
||||
sessionId,
|
||||
launched.sessionId,
|
||||
rfdHistory,
|
||||
this.logger,
|
||||
);
|
||||
await rfdReplay.announce(launched, firstResponseModel(childMessages));
|
||||
rfdReplay.scan(childMessages, launched.sessionId);
|
||||
for (const childMessage of childMessages) await replayMessage(childMessage, launched);
|
||||
await rfdReplay.childReplayed(launched, childMessages);
|
||||
@@ -9688,7 +9718,24 @@ export class ClaudeAcpAgent {
|
||||
this.replayedSubagents.set(sessionId, rfdReplay);
|
||||
}
|
||||
|
||||
// AIR's draft: each child's model goes before its end, which allows no
|
||||
// later update. The model maps to an entry of the session's model list,
|
||||
// which a load knows only when the CLI started (see `sessionReady`).
|
||||
if (nativeReplayEnabled && !pending?.stopped) await pending?.sessionReady;
|
||||
if (nativeReplayEnabled && !pending?.stopped) {
|
||||
for (const child of replayChildren.values()) {
|
||||
const model = child.announced
|
||||
? responseModelEntry(child.responseModel, replayModels)
|
||||
: undefined;
|
||||
if (!model) continue;
|
||||
await this.client.sessionUpdate({
|
||||
sessionId: child.sessionId,
|
||||
update: {
|
||||
sessionUpdate: "config_option_update",
|
||||
configOptions: [childModelOption(model)],
|
||||
},
|
||||
});
|
||||
}
|
||||
// Claude history persists sidechain messages and Agent/Task tool uses,
|
||||
// but not task_started/task_updated lifecycle frames. Recover terminal
|
||||
// state from the launch tool_result. Missing results and malformed or
|
||||
@@ -10159,6 +10206,25 @@ export class ClaudeAcpAgent {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The models of the session `sessionId`, for the `model` option of its
|
||||
* subagents: the entries of the session's own `model` option.
|
||||
*/
|
||||
private subagentModels(sessionId: string): SubagentModels {
|
||||
return {
|
||||
entry: (model) => {
|
||||
const session = this.sessions[sessionId];
|
||||
return session
|
||||
? modelEntryFor(session.configOptions, session.modelInfos, model)
|
||||
: undefined;
|
||||
},
|
||||
name: (model) => {
|
||||
const session = this.sessions[sessionId];
|
||||
return session ? modelDisplayName(session.modelInfos, model) : undefined;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private subagentConfigurationHook(sessionId: string): HookCallback {
|
||||
return async (input) => {
|
||||
const session = this.sessions[sessionId];
|
||||
@@ -10173,16 +10239,12 @@ export class ClaudeAcpAgent {
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (input.hook_event_name === "PostModelSwitch" && input.to_model) {
|
||||
options.push({
|
||||
id: MODEL_CONFIG_ID,
|
||||
name: "Model",
|
||||
category: "model",
|
||||
type: "select",
|
||||
currentValue: input.to_model,
|
||||
options: [{ value: input.to_model, name: input.to_model }],
|
||||
});
|
||||
}
|
||||
const switchedModel =
|
||||
input.hook_event_name === "PostModelSwitch"
|
||||
? responseModelEntry(input.to_model, this.subagentModels(sessionId))
|
||||
: undefined;
|
||||
// The entry of the root session's `model` option, as for a child response.
|
||||
if (switchedModel) options.push(childModelOption(switchedModel));
|
||||
if (input.effort?.level) {
|
||||
options.push({
|
||||
id: EFFORT_CONFIG_ID,
|
||||
@@ -10728,7 +10790,10 @@ export class ClaudeAcpAgent {
|
||||
this.replayedSubagents.delete(sessionId);
|
||||
const runtime = this.sessions[sessionId]?.nativeSubagentRuntime;
|
||||
try {
|
||||
for (const notification of replay.currentStates((child) => runtime?.liveWorkState(child))) {
|
||||
for (const notification of replay.currentStates(
|
||||
(child) => runtime?.liveWorkState(child),
|
||||
(child) => runtime?.configurationOf?.(child),
|
||||
)) {
|
||||
await this.client.sessionUpdate(asSdkSessionNotification(notification));
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -11509,10 +11574,10 @@ export class ClaudeAcpAgent {
|
||||
`[session/query] sessionId=${sessionId} resume=${creationOpts?.resume ?? "none"} apiType=${resolvedProvider?.apiType ?? "native"} baseUrl=${resolvedProvider?.baseUrl ?? "native"}`,
|
||||
);
|
||||
|
||||
const subagentConfigHook =
|
||||
subagentForm(this.clientCapabilities) === "rfd"
|
||||
? this.subagentConfigurationHook(sessionId)
|
||||
: undefined;
|
||||
// Both forms of native subagent sessions report the configuration of a child.
|
||||
const subagentConfigHook = subagentForm(this.clientCapabilities)
|
||||
? this.subagentConfigurationHook(sessionId)
|
||||
: undefined;
|
||||
const options: Options = {
|
||||
systemPrompt,
|
||||
settingSources: ["user", "project", "local"],
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from "@agentclientprotocol/sdk/unstable";
|
||||
import type { AcpSessionNotification, SubagentForm, SubagentState } from "./acp-subagents.js";
|
||||
import { AIR_SUBAGENT_KEY, airExtensionMeta } from "./air-extension.js";
|
||||
import { MODEL_CONFIG_ID } from "./session-model.js";
|
||||
import { isFinalToolCallStatus } from "./tool-calls/renderer.js";
|
||||
|
||||
/** The work state of a child, as the RFD's `subagent_update.state` carries it. */
|
||||
@@ -68,6 +69,24 @@ export type NativeSubagentSession = {
|
||||
type Publish = (notification: AcpSessionNotification) => Promise<void>;
|
||||
type Logger = { log(message: string): void };
|
||||
|
||||
/** An entry of a session's `model` select option. */
|
||||
export type ModelEntry = { value: string; name: string };
|
||||
|
||||
/**
|
||||
* The models of the root session. A child's model is an entry of the root
|
||||
* session's `model` option when one matches, so that a client can compare the
|
||||
* two sessions' models.
|
||||
*/
|
||||
export type SubagentModels = {
|
||||
/**
|
||||
* The entry of the root session's `model` option for `model`, a model id
|
||||
* that a response reported. Undefined when no entry matches.
|
||||
*/
|
||||
entry(model: string): ModelEntry | undefined;
|
||||
/** The display name of the model id `model` in the session's model list, if the list has it. */
|
||||
name(model: string): string | undefined;
|
||||
};
|
||||
|
||||
type ChildConfiguration = {
|
||||
options: Map<string, SessionConfigOption>;
|
||||
published?: string;
|
||||
@@ -204,6 +223,8 @@ export class NativeSubagentRuntime {
|
||||
* `form` is undefined for a client without native subagent sessions. The RFD
|
||||
* form restores an agent from before a load or resume with `lineage`. The AIR
|
||||
* form does too, and `launchedAgent` finds the agent that a launch call made.
|
||||
* `models` maps the model of a child response to the root session's entry
|
||||
* (see {@link reportModel}).
|
||||
*/
|
||||
constructor(
|
||||
form: SubagentForm | undefined,
|
||||
@@ -213,6 +234,7 @@ export class NativeSubagentRuntime {
|
||||
private readonly logger: Logger,
|
||||
private readonly lineage?: SubagentLineage,
|
||||
private readonly launchedAgent?: LaunchedAgent,
|
||||
private readonly models?: SubagentModels,
|
||||
) {
|
||||
this.form = form;
|
||||
this.enabled = form !== undefined;
|
||||
@@ -247,58 +269,69 @@ export class NativeSubagentRuntime {
|
||||
return child?.announced ? child.workState : undefined;
|
||||
}
|
||||
|
||||
/** Report the model of a child response, not the parent's model or a launch-time alias. */
|
||||
/**
|
||||
* Reports the model of a child response, an exact model id, as the entry of
|
||||
* the root session's `model` option that matches it, else as the id (see
|
||||
* {@link responseModelEntry}). The child gets no model before its first
|
||||
* response: the adapter cannot tell the model that Claude Code picks for it.
|
||||
* The client gets a new configuration only when the entry changes.
|
||||
*/
|
||||
async reportModel(
|
||||
parentToolUseId: string | null,
|
||||
model: string | undefined,
|
||||
deliver: Publish,
|
||||
): Promise<void> {
|
||||
if (this.form !== "rfd" || !parentToolUseId || !model || model === "<synthetic>") {
|
||||
if (!this.form || !parentToolUseId || !model || model === "<synthetic>") {
|
||||
return;
|
||||
}
|
||||
await deliver({
|
||||
sessionId: this.rootSessionId,
|
||||
update: {
|
||||
sessionUpdate: "config_option_update",
|
||||
configOptions: [
|
||||
{
|
||||
id: "model",
|
||||
name: "Model",
|
||||
category: "model",
|
||||
type: "select",
|
||||
currentValue: model,
|
||||
options: [{ value: model, name: model }],
|
||||
},
|
||||
],
|
||||
configOptions: [childModelOption(responseModelEntry(model, this.models)!)],
|
||||
_meta: { claudeCode: { parentToolUseId } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Hook observations can precede the task event that establishes a child's parent. */
|
||||
/** The configuration that the client last got for the announced child `sessionId`, if any. */
|
||||
configurationOf(sessionId: string): SessionConfigOption[] | undefined {
|
||||
const published = this.configBySession.get(sessionId)?.published;
|
||||
if (!this.childOfSession(sessionId)?.announced || !published) return undefined;
|
||||
return JSON.parse(published) as SessionConfigOption[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes configuration options of the child `agentId`: an SDK agent id, as a
|
||||
* hook names it, or a child session id. Hook observations can precede the
|
||||
* task event that establishes a child's parent.
|
||||
*/
|
||||
async reportConfiguration(
|
||||
agentId: string,
|
||||
options: SessionConfigOption[],
|
||||
meta?: SessionNotification["update"]["_meta"],
|
||||
): Promise<void> {
|
||||
if (this.form !== "rfd" || agentId === this.rootSessionId || options.length === 0) return;
|
||||
let config = this.configBySession.get(agentId);
|
||||
if (!this.form || agentId === this.rootSessionId || options.length === 0) return;
|
||||
// In AIR's draft, the session id of a resumed or restored agent is
|
||||
// `<agent id>:generation:<n>`, so the agent id names its current generation.
|
||||
const known = this.children.get(agentId) ?? this.childOfSession(agentId);
|
||||
const sessionId = known?.sessionId ?? agentId;
|
||||
let config = this.configBySession.get(sessionId);
|
||||
if (!config) {
|
||||
const pending = [...this.configBySession.keys()].filter(
|
||||
(id) => !this.children.has(id),
|
||||
(id) => !this.childOfSession(id),
|
||||
).length;
|
||||
if (!this.children.has(agentId) && pending >= MAX_PENDING_PARENTS) {
|
||||
if (!known && pending >= MAX_PENDING_PARENTS) {
|
||||
this.logger.log(
|
||||
`Session ${this.rootSessionId}: dropping configuration for unannounced subagent ${agentId}; pending buffer limit reached`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
config = { options: new Map(), publishing: Promise.resolve() };
|
||||
this.configBySession.set(agentId, config);
|
||||
this.configBySession.set(sessionId, config);
|
||||
}
|
||||
for (const option of options) config.options.set(option.id, option);
|
||||
const child = this.children.get(agentId);
|
||||
if (child?.announced) await this.publishConfiguration(child, meta);
|
||||
if (known?.announced) await this.publishConfiguration(known, meta);
|
||||
}
|
||||
|
||||
private async publishConfiguration(
|
||||
@@ -306,11 +339,15 @@ export class NativeSubagentRuntime {
|
||||
meta?: SessionNotification["update"]["_meta"],
|
||||
): Promise<void> {
|
||||
const config = this.configBySession.get(child.sessionId);
|
||||
if (!config) return;
|
||||
// AIR's draft ends a child for good, so it gets no update after its end,
|
||||
// also when the end comes while this publication waits for an earlier one.
|
||||
const ended = () =>
|
||||
this.form === "air" && (child.terminalState !== undefined || !!child.terminalPromise);
|
||||
if (!config || ended()) return;
|
||||
// SDK hooks can run concurrently with streamed responses. Serialize snapshots
|
||||
// so a delayed publication cannot replace newer settings with older ones.
|
||||
const publishing = config.publishing.then(async () => {
|
||||
if (this.configBySession.get(child.sessionId) !== config) return;
|
||||
if (this.configBySession.get(child.sessionId) !== config || ended()) return;
|
||||
const priority = ["mode", "model", "thought_level", "model_config"];
|
||||
const rank = (option: SessionConfigOption) => {
|
||||
const index = priority.indexOf(option.category ?? "");
|
||||
@@ -435,15 +472,11 @@ export class NativeSubagentRuntime {
|
||||
(claudeMeta?.parentToolUseId
|
||||
? this.childByParentToolUse.get(claudeMeta.parentToolUseId)
|
||||
: undefined);
|
||||
if (announcedChild?.announced && update.sessionUpdate === "config_option_update") {
|
||||
await this.reportConfiguration(announcedChild.sessionId, update.configOptions, update._meta);
|
||||
return null;
|
||||
}
|
||||
if (this.form === "rfd" && announcedChild?.announced) {
|
||||
if (update.sessionUpdate === "config_option_update") {
|
||||
await this.reportConfiguration(
|
||||
announcedChild.sessionId,
|
||||
update.configOptions,
|
||||
update._meta,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const handback = await this.routeRfdHandback(notification, announcedChild);
|
||||
if (handback !== undefined) return handback;
|
||||
}
|
||||
@@ -1062,9 +1095,13 @@ export class NativeSubagentRuntime {
|
||||
await this.reportState(child, rfdEndState("failed", { error }));
|
||||
}
|
||||
|
||||
/** Announces `child` in the client's form. A runtime without one never does. */
|
||||
/**
|
||||
* Announces `child` in the client's form, then its configuration. A runtime
|
||||
* without a form never does.
|
||||
*/
|
||||
private async announce(child: NativeSubagent): Promise<void> {
|
||||
if (this.form) await announceNativeSubagent(child, this.publish, this.form);
|
||||
if (!this.form) return;
|
||||
await announceNativeSubagent(child, this.publish, this.form);
|
||||
await this.publishConfiguration(child);
|
||||
}
|
||||
|
||||
@@ -1475,6 +1512,35 @@ export class NativeSubagentRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The `model` option of a child session. The client cannot change a child's
|
||||
* configuration, so the select offers only the current model.
|
||||
*/
|
||||
export function childModelOption(entry: ModelEntry): SessionConfigOption {
|
||||
return {
|
||||
id: MODEL_CONFIG_ID,
|
||||
name: "Model",
|
||||
category: "model",
|
||||
type: "select",
|
||||
currentValue: entry.value,
|
||||
options: [{ value: entry.value, name: entry.name }],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The model entry for `model`, the model id of a child's response: the entry
|
||||
* of the root session's `model` option that matches it, else the id, with its
|
||||
* display name when the session's model list has one. Undefined for no model,
|
||||
* and for `<synthetic>`, which marks a message that no model wrote.
|
||||
*/
|
||||
export function responseModelEntry(
|
||||
model: unknown,
|
||||
models: SubagentModels | undefined,
|
||||
): ModelEntry | undefined {
|
||||
if (typeof model !== "string" || !model || model === "<synthetic>") return undefined;
|
||||
return models?.entry(model) ?? { value: model, name: models?.name(model) ?? model };
|
||||
}
|
||||
|
||||
/**
|
||||
* Announces the child on its parent, once, before any update of the child.
|
||||
* The RFD form announces it as running and then sends the prompt as the
|
||||
|
||||
@@ -606,3 +606,58 @@ export async function getAvailableModels(
|
||||
currentModelId: currentModel.value,
|
||||
};
|
||||
}
|
||||
|
||||
/** The display name of the model id `model` in `modelInfos`, if it is there. */
|
||||
export function modelDisplayName(modelInfos: ModelInfo[], model: string): string | undefined {
|
||||
return modelInfos.find((info) => info.value === model || info.resolvedModel === model)
|
||||
?.displayName;
|
||||
}
|
||||
|
||||
/** The flat entries of a session's `model` select option. */
|
||||
function modelOptionEntries(option: SessionConfigOption): Array<{ value: string; name: string }> {
|
||||
if (option.type !== "select") return [];
|
||||
return option.options.flatMap((entry) =>
|
||||
"value" in entry ? [{ value: entry.value, name: entry.name }] : entry.options,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The entry of the `model` option among `configOptions` for `model`, a model
|
||||
* id that a response reported, so that a client can compare it with the
|
||||
* session's own model. Best first:
|
||||
*
|
||||
* 1. the current entry, when it runs that model up to a context hint: a
|
||||
* session on `opus[1m]` or `default` that answers as `claude-opus-4-6`;
|
||||
* 2. the entry whose value or resolved model is that id;
|
||||
* 3. an entry that runs that model up to a context hint.
|
||||
*
|
||||
* Among several entries for one model, a concrete entry comes before
|
||||
* `default`, as in {@link resolveModelPreference}. Undefined when no entry
|
||||
* matches. A model's resolved id comes from `modelInfos`
|
||||
* (`ModelInfo.resolvedModel`), the wire id that a response reports, as for
|
||||
* {@link matchResumedModel}. It is compared as it is: a dated id such as
|
||||
* `claude-haiku-4-5-20251001` matches only the same dated id.
|
||||
*/
|
||||
export function modelEntryFor(
|
||||
configOptions: readonly SessionConfigOption[],
|
||||
modelInfos: ModelInfo[],
|
||||
model: string,
|
||||
): { value: string; name: string } | undefined {
|
||||
const option = configOptions.find((candidate) => candidate.id === MODEL_CONFIG_ID);
|
||||
if (option?.type !== "select") return undefined;
|
||||
// Concrete entries first; `sort` keeps the list order otherwise.
|
||||
const entries = modelOptionEntries(option).sort(
|
||||
(left, right) => Number(left.value === "default") - Number(right.value === "default"),
|
||||
);
|
||||
const observed = canonicalizeModelId(model);
|
||||
const resolved = (value: string) =>
|
||||
canonicalizeModelId(modelInfos.find((info) => info.value === value)?.resolvedModel ?? value);
|
||||
const sameModel = (value: string) =>
|
||||
stripContextHints(resolved(value)) === stripContextHints(observed);
|
||||
const current = entries.find((entry) => entry.value === option.currentValue);
|
||||
if (current && sameModel(current.value)) return current;
|
||||
return (
|
||||
entries.find((entry) => entry.value === model || resolved(entry.value) === observed) ??
|
||||
entries.find((entry) => sameModel(entry.value))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,21 +22,32 @@
|
||||
* 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.
|
||||
*
|
||||
* Right after its announcement, each child gets its `model` option: the model
|
||||
* of its first response, in its transcript. A later response with another
|
||||
* model sends it again. A child without responses gets none. After the load
|
||||
* response, a model goes again only when the session's model list, which the
|
||||
* replay can lack, maps it to another entry.
|
||||
*
|
||||
* A launch without metadata has no known id, so it is not announced: the RFD
|
||||
* forbids inventing one. Its Agent call stays an ordinary tool call of the
|
||||
* parent, and a notice says that subagent history is missing.
|
||||
*/
|
||||
import type { SessionConfigOption } from "@agentclientprotocol/sdk/unstable";
|
||||
import type { SessionMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { AcpSessionNotification, SubagentState } from "./acp-subagents.js";
|
||||
import {
|
||||
childModelOption,
|
||||
HANDBACK_TOOL,
|
||||
handbackMessage,
|
||||
isNativeSubagentControlTool,
|
||||
launchReply,
|
||||
linkedSessionMessages,
|
||||
type ModelEntry,
|
||||
nativeSubagentState,
|
||||
responseModelEntry,
|
||||
rfdEndState,
|
||||
rfdLabels,
|
||||
type SubagentModels,
|
||||
type WorkState,
|
||||
} from "./native-subagents.js";
|
||||
import { noticeUpdate } from "./session-notices.js";
|
||||
@@ -63,6 +74,10 @@ export type ReplayChild = {
|
||||
promptSent: boolean;
|
||||
/** Whether it handed a report to its parent with a SubagentHandback call. */
|
||||
handedBack?: boolean;
|
||||
/** The `model` option entry that the replay sent. */
|
||||
model?: ModelEntry;
|
||||
/** The model id of the latest response in its transcript. */
|
||||
responseModel?: string;
|
||||
};
|
||||
|
||||
type SendMessageCall = {
|
||||
@@ -96,6 +111,7 @@ export class RfdSubagentReplay {
|
||||
constructor(
|
||||
private readonly history: SubagentHistory,
|
||||
private readonly publish: Publish,
|
||||
private readonly models?: SubagentModels,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -142,8 +158,12 @@ export class RfdSubagentReplay {
|
||||
return this.children.get(toolUseId);
|
||||
}
|
||||
|
||||
/** Announces `child` on its parent, as live: running, with the parent's labels. */
|
||||
async announce(child: ReplayChild): Promise<void> {
|
||||
/**
|
||||
* Announces `child` on its parent, as live: running, with the parent's
|
||||
* labels. Then it sends `firstModel`, the model of the child's first
|
||||
* response, when the transcript has one.
|
||||
*/
|
||||
async announce(child: ReplayChild, firstModel?: string): Promise<void> {
|
||||
if (child.announced) return;
|
||||
const labels = rfdLabels(
|
||||
child.record.name ?? child.input.name,
|
||||
@@ -161,6 +181,30 @@ export class RfdSubagentReplay {
|
||||
},
|
||||
});
|
||||
child.announced = true;
|
||||
await this.responseModel(child, firstModel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes the model of a replayed response of the child `owner`. A child
|
||||
* whose model changes gets its `model` option again.
|
||||
*/
|
||||
async responseModel(owner: ReplayChild, model: string | undefined): Promise<void> {
|
||||
const entry = responseModelEntry(model, this.models);
|
||||
if (!entry || !owner.announced) return;
|
||||
owner.responseModel = model;
|
||||
await this.sendModel(owner, entry);
|
||||
}
|
||||
|
||||
private async sendModel(child: ReplayChild, model: ModelEntry): Promise<void> {
|
||||
if (child.model?.value === model.value && child.model.name === model.name) return;
|
||||
child.model = model;
|
||||
await this.publish({
|
||||
sessionId: child.sessionId,
|
||||
update: {
|
||||
sessionUpdate: "config_option_update",
|
||||
configOptions: [childModelOption(model)],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -323,22 +367,40 @@ export class RfdSubagentReplay {
|
||||
*/
|
||||
currentStates(
|
||||
live: (sessionId: string) => WorkState["state"] | undefined,
|
||||
liveConfiguration?: (sessionId: string) => SessionConfigOption[] | undefined,
|
||||
): AcpSessionNotification[] {
|
||||
return [...this.children.values()]
|
||||
.filter((child) => child.announced)
|
||||
.map((child) => {
|
||||
.flatMap((child): AcpSessionNotification[] => {
|
||||
const liveState = live(child.sessionId);
|
||||
const state: WorkState =
|
||||
liveState === undefined || liveState === "idle" ? endState(child) : { state: liveState };
|
||||
return {
|
||||
sessionId: child.parentSessionId,
|
||||
update: {
|
||||
sessionUpdate: "subagent_update",
|
||||
sessionId: child.sessionId,
|
||||
...(liveState !== undefined ? { capabilities: { cancel: {} } } : {}),
|
||||
state,
|
||||
// The session's model list may be known only now, after the load response.
|
||||
const entry = responseModelEntry(child.responseModel, this.models);
|
||||
const replayed = child.model ? JSON.stringify([childModelOption(child.model)]) : undefined;
|
||||
const current =
|
||||
liveConfiguration?.(child.sessionId) ?? (entry ? [childModelOption(entry)] : undefined);
|
||||
// A configuration that the replay already sent does not go again.
|
||||
const configOptions = current && JSON.stringify(current) !== replayed ? current : undefined;
|
||||
return [
|
||||
{
|
||||
sessionId: child.parentSessionId,
|
||||
update: {
|
||||
sessionUpdate: "subagent_update",
|
||||
sessionId: child.sessionId,
|
||||
...(liveState !== undefined ? { capabilities: { cancel: {} } } : {}),
|
||||
state,
|
||||
},
|
||||
},
|
||||
};
|
||||
...(configOptions
|
||||
? [
|
||||
{
|
||||
sessionId: child.sessionId,
|
||||
update: { sessionUpdate: "config_option_update" as const, configOptions },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -433,6 +495,16 @@ export class RfdSubagentReplay {
|
||||
}
|
||||
}
|
||||
|
||||
/** The model of the first response in `messages`, a subagent's transcript. */
|
||||
export function firstResponseModel(messages: readonly SessionMessage[]): string | undefined {
|
||||
for (const message of messages) {
|
||||
if (message.type !== "assistant") continue;
|
||||
const model = (message.message as { model?: unknown } | undefined)?.model;
|
||||
if (typeof model === "string" && model && model !== "<synthetic>") return model;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The end of a foreground launch from its tool result: an error result that
|
||||
* names a stop is a cancellation, any other error a failure.
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
RequestError,
|
||||
RequestPermissionRequest,
|
||||
RequestPermissionResponse,
|
||||
SessionConfigOption,
|
||||
SessionNotification,
|
||||
WriteTextFileRequest,
|
||||
WriteTextFileResponse,
|
||||
@@ -67,6 +68,7 @@ import {
|
||||
type HookCallback,
|
||||
importSessionToStore,
|
||||
listSessions,
|
||||
type ModelInfo,
|
||||
PermissionUpdate,
|
||||
query,
|
||||
SDKAssistantMessage,
|
||||
@@ -3584,6 +3586,81 @@ describe("subagent transcript replay", () => {
|
||||
return updates;
|
||||
}
|
||||
|
||||
it("sends an AIR child's model as the session's entry once the session exists, before its end", async () => {
|
||||
const updates: AcpSessionNotification[] = [];
|
||||
const agent = new ClaudeAcpAgent(
|
||||
{
|
||||
sessionUpdate: async (update: SessionNotification) =>
|
||||
updates.push(update as AcpSessionNotification),
|
||||
} as unknown as AcpClient,
|
||||
{ log: () => {}, error: () => {} },
|
||||
);
|
||||
await initializeClient(agent, AIR_NATIVE_SUBAGENTS);
|
||||
// A load replays while the CLI starts: the session and its model list come later.
|
||||
let sessionCreated!: () => void;
|
||||
const pending = {
|
||||
state: { cwd: "/tmp", taskState: new Map(), messageIdToUuid: new Map() },
|
||||
stopped: false,
|
||||
sessionReady: new Promise<void>((resolve) => (sessionCreated = resolve)),
|
||||
};
|
||||
const replaying = (
|
||||
agent as unknown as {
|
||||
replaySessionHistory(
|
||||
sessionId: string,
|
||||
messages: unknown[],
|
||||
pending: unknown,
|
||||
): Promise<void>;
|
||||
}
|
||||
).replaySessionHistory("s1", replayHistory, pending);
|
||||
await vi.waitFor(() =>
|
||||
expect(updates.map(({ update }) => update.sessionUpdate)).toContain("tool_call"),
|
||||
);
|
||||
expect(updates.map(({ update }) => update.sessionUpdate)).not.toContain("config_option_update");
|
||||
(agent.sessions as Record<string, unknown>).s1 = {
|
||||
configOptions: [
|
||||
{
|
||||
id: "model",
|
||||
name: "Model",
|
||||
category: "model",
|
||||
type: "select",
|
||||
currentValue: "sonnet",
|
||||
options: [{ value: "sonnet", name: "Sonnet 4.5" }],
|
||||
},
|
||||
],
|
||||
modelInfos: [
|
||||
{
|
||||
value: "sonnet",
|
||||
resolvedModel: "claude-sonnet-4-5",
|
||||
displayName: "Sonnet 4.5",
|
||||
description: "",
|
||||
},
|
||||
],
|
||||
};
|
||||
sessionCreated();
|
||||
await replaying;
|
||||
expect(updates.slice(-2)).toEqual([
|
||||
{
|
||||
sessionId: "s1:replay-subagent:parent-agent-call",
|
||||
update: {
|
||||
sessionUpdate: "config_option_update",
|
||||
configOptions: [
|
||||
{
|
||||
id: "model",
|
||||
name: "Model",
|
||||
category: "model",
|
||||
type: "select",
|
||||
currentValue: "sonnet",
|
||||
options: [{ value: "sonnet", name: "Sonnet 4.5" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
expect.objectContaining({
|
||||
update: expect.objectContaining({ sessionUpdate: "subagent_state_update" }),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("replays a subagent from its own transcript file", async () => {
|
||||
// Claude keeps a subagent history in <session>/subagents/, and the SDK
|
||||
// returns the main transcript without it and without parent_tool_use_id.
|
||||
@@ -3742,8 +3819,11 @@ describe("subagent transcript replay", () => {
|
||||
[child1, "user_message_chunk", "Look around"],
|
||||
[child1, "agent_message_chunk", "Child report."],
|
||||
// A launch without a subagent file still gets its child and its state.
|
||||
// Nothing names its model, so it gets none.
|
||||
[sessionId, "subagent_spawned", child2],
|
||||
[sessionId, "agent_message_chunk", "Done."],
|
||||
// The model of the child's responses goes before its end, which allows no later update.
|
||||
[child1, "config_option_update", undefined],
|
||||
[sessionId, "subagent_state_update", child2],
|
||||
[sessionId, "subagent_state_update", child1],
|
||||
[sessionId, "session_info_update", undefined],
|
||||
@@ -3808,7 +3888,12 @@ describe("subagent transcript replay", () => {
|
||||
root: object[],
|
||||
children: Record<string, { meta: object; records: object[] }>,
|
||||
capabilities: ClientCapabilities = { subagents: {}, session: { notices: {} } },
|
||||
afterResponse?: { live: (childSessionId: string) => string | undefined },
|
||||
afterResponse?: {
|
||||
live: (childSessionId: string) => string | undefined;
|
||||
/** The session's config options and model list, known after the response. */
|
||||
models?: { configOptions: SessionConfigOption[]; modelInfos: ModelInfo[] };
|
||||
},
|
||||
{ traceModels = false }: { traceModels?: boolean } = {},
|
||||
): Promise<string[]> {
|
||||
const configDir = await mkdtemp(path.join(os.tmpdir(), "claude-acp-rfd-replay-"));
|
||||
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
||||
@@ -3854,6 +3939,8 @@ describe("subagent transcript replay", () => {
|
||||
// Stands in for the live session's runtime, which knows its running children.
|
||||
(agent.sessions as Record<string, unknown>)[sessionId] = {
|
||||
nativeSubagentRuntime: { liveWorkState: afterResponse.live },
|
||||
configOptions: afterResponse.models?.configOptions ?? [],
|
||||
modelInfos: afterResponse.models?.modelInfos ?? [],
|
||||
};
|
||||
await agent.publishCurrentSubagentStates(sessionId);
|
||||
}
|
||||
@@ -3883,6 +3970,13 @@ describe("subagent transcript replay", () => {
|
||||
}
|
||||
case "tool_call":
|
||||
return [`${on} tool_call ${u.toolCallId}`];
|
||||
case "config_option_update":
|
||||
return traceModels
|
||||
? u.configOptions.map(
|
||||
(option: any) =>
|
||||
`${on} model ${option.currentValue} ${JSON.stringify(option.options)}`,
|
||||
)
|
||||
: [];
|
||||
case "notice":
|
||||
return [`${on} notice ${u.title}: ${u.description}`];
|
||||
case "user_message_chunk":
|
||||
@@ -4102,6 +4196,120 @@ describe("subagent transcript replay", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("sends the model of each child's responses, and after the load response only a changed entry", async () => {
|
||||
const launch = (id: string, name: string, input: object = {}) =>
|
||||
toolUse(id, "Agent", {
|
||||
name,
|
||||
subagent_type: "general-purpose",
|
||||
prompt: `Run ${name}`,
|
||||
...input,
|
||||
});
|
||||
const answer = (uuid: string, model: string, text: string) => ({
|
||||
...assistant(uuid, []),
|
||||
message: {
|
||||
id: `m-${uuid}`,
|
||||
role: "assistant",
|
||||
model,
|
||||
content: [{ type: "text", text }],
|
||||
},
|
||||
});
|
||||
const meta = (name: string, launchId: string) => ({
|
||||
agentType: "general-purpose",
|
||||
name,
|
||||
toolUseId: launchId,
|
||||
requestShape: "foreground",
|
||||
});
|
||||
const trace = await replayRfd(
|
||||
[
|
||||
user("u1", "Go"),
|
||||
assistant("a1", [
|
||||
launch("toolu_answered", "answered"),
|
||||
launch("toolu_unlisted", "unlisted", { model: "haiku" }),
|
||||
launch("toolu_silent", "silent"),
|
||||
]),
|
||||
user("u2", [
|
||||
toolResult("toolu_answered", "Done."),
|
||||
toolResult("toolu_unlisted", "Done."),
|
||||
toolResult("toolu_silent", "Done."),
|
||||
]),
|
||||
],
|
||||
{
|
||||
aaa111: {
|
||||
meta: meta("answered", "toolu_answered"),
|
||||
records: [
|
||||
user("x1", "Run answered"),
|
||||
answer("x2", "claude-haiku-4-5", "First."),
|
||||
answer("x3", "claude-haiku-4-5", "Same model."),
|
||||
answer("x4", "claude-opus-5", "Fallback."),
|
||||
],
|
||||
},
|
||||
// A model that the session's list does not have.
|
||||
bbb222: {
|
||||
meta: meta("unlisted", "toolu_unlisted"),
|
||||
records: [user("y1", "Run unlisted"), answer("y2", "claude-fable-9", "Done.")],
|
||||
},
|
||||
// No response: the replay does not guess the launch input's or the parent's model.
|
||||
ccc333: { meta: meta("silent", "toolu_silent"), records: [user("z1", "Run silent")] },
|
||||
},
|
||||
undefined,
|
||||
{
|
||||
live: () => undefined,
|
||||
models: {
|
||||
configOptions: [
|
||||
{
|
||||
id: "model",
|
||||
name: "Model",
|
||||
category: "model",
|
||||
type: "select",
|
||||
currentValue: "opus",
|
||||
options: [
|
||||
{ value: "opus", name: "Opus 5" },
|
||||
{ value: "haiku", name: "Haiku 4.5" },
|
||||
],
|
||||
},
|
||||
],
|
||||
modelInfos: [
|
||||
{
|
||||
value: "opus",
|
||||
displayName: "Opus 5",
|
||||
description: "",
|
||||
resolvedModel: "claude-opus-5",
|
||||
},
|
||||
{
|
||||
value: "haiku",
|
||||
displayName: "Haiku 4.5",
|
||||
description: "",
|
||||
resolvedModel: "claude-haiku-4-5",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ traceModels: true },
|
||||
);
|
||||
|
||||
const opus = JSON.stringify([{ value: "opus", name: "Opus 5" }]);
|
||||
const raw = (model: string) => JSON.stringify([{ value: model, name: model }]);
|
||||
expect(trace.filter((line) => / (model|subagent_update) /.test(line))).toEqual([
|
||||
// During the replay the session has no model list yet, so the model is the id.
|
||||
'root subagent_update aaa111 {"state":"running"}',
|
||||
`aaa111 model claude-haiku-4-5 ${raw("claude-haiku-4-5")}`,
|
||||
// A response on another model sends the model again; the same model does not.
|
||||
`aaa111 model claude-opus-5 ${raw("claude-opus-5")}`,
|
||||
'root subagent_update bbb222 {"state":"running"}',
|
||||
`bbb222 model claude-fable-9 ${raw("claude-fable-9")}`,
|
||||
'root subagent_update ccc333 {"state":"running"}',
|
||||
'root subagent_update ccc333 {"state":"idle","stopReason":"end_turn"}',
|
||||
'root subagent_update bbb222 {"state":"idle","stopReason":"end_turn"}',
|
||||
'root subagent_update aaa111 {"state":"idle","stopReason":"end_turn"}',
|
||||
// After the response, a model goes again only when the root session's
|
||||
// `model` option maps it to another entry.
|
||||
'root subagent_update aaa111 {"state":"idle","stopReason":"end_turn"}',
|
||||
`aaa111 model opus ${opus}`,
|
||||
'root subagent_update bbb222 {"state":"idle","stopReason":"end_turn"}',
|
||||
'root subagent_update ccc333 {"state":"idle","stopReason":"end_turn"}',
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a SendMessage call that the replay reaches before its delivery as a tool call", async () => {
|
||||
const launch = (id: string, name: string) =>
|
||||
toolUse(id, "Agent", { name, subagent_type: "Explore", prompt: `Run ${name}` });
|
||||
@@ -4249,6 +4457,8 @@ describe("subagent transcript replay", () => {
|
||||
["s1", "subagent_spawned"],
|
||||
["s1:replay-subagent:parent-agent-call", "agent_message_chunk"],
|
||||
["s1:replay-subagent:parent-agent-call", "tool_call"],
|
||||
// The model of its response, before its end.
|
||||
["s1:replay-subagent:parent-agent-call", "config_option_update"],
|
||||
["s1", "subagent_state_update"],
|
||||
]);
|
||||
expect(updates[0]?.update).toMatchObject({
|
||||
@@ -8667,6 +8877,8 @@ describe("subagent permission attribution (issue #851)", () => {
|
||||
expect(updates.map(({ sessionId, update }) => [sessionId, update.sessionUpdate])).toEqual([
|
||||
["test-session", "subagent_spawned"],
|
||||
["agent-42", "agent_message_chunk"],
|
||||
// The model of the child's response.
|
||||
["agent-42", "config_option_update"],
|
||||
["agent-42", "tool_call"],
|
||||
["agent-42", "agent_message_chunk"],
|
||||
["test-session", "subagent_state_update"],
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"rawOutput":"The file <cwd>/src/app.ts has been updated successfully.","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_r_edit"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>:replay-subagent:toolu_r_task","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_r_task"}},"content":{"text":"Subagent text.","type":"text"},"messageId":"msg_r_sub","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":{"text":"Fixed.","type":"text"},"messageId":"msg_r2","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>:replay-subagent:toolu_r_task","update":{"configOptions":[{"category":"model","currentValue":"claude-sonnet-4-6","id":"model","name":"Model","options":[{"name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"subagent_state_update","state":"completed","subagentSessionId":"<session>:replay-subagent:toolu_r_task"}}}
|
||||
{"kind":"loadSession","payload":{"configOptions":[{"category":"mode","currentValue":"default","description":"Session permission mode","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Always ask before making changes","name":"Manual","value":"default"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"_meta":{"jetbrains":{"air":{"kind":"plan","version":1}}},"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","id":"model","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Always ask before making changes","id":"default","name":"Manual"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Automatically accept all file edits","id":"acceptEdits","name":"Accept edits"},{"_meta":{"jetbrains":{"air":{"kind":"plan","version":1}}},"description":"Create a plan before making changes","id":"plan","name":"Plan"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Claude handles permission decisions","id":"auto","name":"Auto"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Accepts all permissions","id":"bypassPermissions","name":"Bypass permissions"}],"currentModeId":"default"},"sessionId":"<session>"}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"capabilities":{},"name":"Background review","prompt":"Review the diff","sessionUpdate":"subagent_spawned","subagentSessionId":"a2","task":"Review the diff"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"a2","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_agent"}},"configOptions":[{"category":"model","currentValue":"claude-sonnet-4-6","id":"model","name":"Model","options":[{"name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"a2","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_agent"}},"content":{"text":"Reviewi","type":"text"},"messageId":"msg_async_sub","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"a2","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_agent"}},"content":{"text":"ng now.","type":"text"},"messageId":"msg_async_sub","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"subagent_state_update","state":"completed","subagentSessionId":"a2"}}}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"capabilities":{},"name":"Check the logs","prompt":"Check logs","sessionUpdate":"subagent_spawned","subagentSessionId":"agent_late","task":"Check logs"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_late","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_late"}},"configOptions":[{"category":"model","currentValue":"claude-sonnet-4-6","id":"model","name":"Model","options":[{"name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_late","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_late","toolName":"Read"}},"content":[],"kind":"read","locations":[],"name":"Read","sessionUpdate":"tool_call","status":"pending","title":"Read File","toolCallId":"toolu_late_read"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_late","update":{"locations":[{"line":1,"path":"/var/log/app.log"}],"rawInput":{"file_path":"/var/log/app.log"},"sessionUpdate":"tool_call_update","title":"Read /var/log/app.log","toolCallId":"toolu_late_read"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"subagent_state_update","state":"completed","subagentSessionId":"agent_late"}}}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"capabilities":{},"name":"Fix the build","prompt":"Make the build pass","sessionUpdate":"subagent_spawned","subagentSessionId":"agent_n","task":"Make the build pass"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_n","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_native"}},"configOptions":[{"category":"model","currentValue":"claude-sonnet-4-6","id":"model","name":"Model","options":[{"name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_n","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_native"}},"content":{"text":"Running t","type":"text"},"messageId":"msg_native_sub_1","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_n","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_native"}},"content":{"text":"he build.","type":"text"},"messageId":"msg_native_sub_1","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_n","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_native","toolName":"Grep"}},"content":[],"kind":"search","name":"Grep","sessionUpdate":"tool_call","status":"pending","title":"grep","toolCallId":"toolu_native_grep"}}}
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"capabilities":{},"name":"Plan the work","prompt":"Plan it","sessionUpdate":"subagent_spawned","subagentSessionId":"agent_outer","task":"Plan it"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_outer","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_outer"}},"configOptions":[{"category":"model","currentValue":"claude-sonnet-4-6","id":"model","name":"Model","options":[{"name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_outer","update":{"capabilities":{},"name":"Read the spec","prompt":"Read spec.md","sessionUpdate":"subagent_spawned","subagentSessionId":"agent_inner","task":"Read spec.md"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_inner","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_inner"}},"configOptions":[{"category":"model","currentValue":"claude-sonnet-4-6","id":"model","name":"Model","options":[{"name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_inner","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_inner","toolName":"Read"}},"content":[],"kind":"read","locations":[],"name":"Read","sessionUpdate":"tool_call","status":"pending","title":"Read File","toolCallId":"toolu_grandchild_read"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_inner","update":{"locations":[{"line":1,"path":"/spec.md"}],"rawInput":{"file_path":"/spec.md"},"sessionUpdate":"tool_call_update","title":"Read /spec.md","toolCallId":"toolu_grandchild_read"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_inner","update":{"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_grandchild_read"}}}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"capabilities":{},"name":"Explore code","prompt":"Find the parser","sessionUpdate":"subagent_spawned","subagentSessionId":"agent_1","task":"Find the parser"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_1","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task"}},"configOptions":[{"category":"model","currentValue":"claude-sonnet-4-6","id":"model","name":"Model","options":[{"name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_1","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task"}},"content":{"text":"Looking for","type":"text"},"messageId":"msg_sub_1","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_1","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task"}},"content":{"text":" the parser.","type":"text"},"messageId":"msg_sub_1","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_1","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task","toolName":"Read"}},"content":[],"kind":"read","locations":[],"name":"Read","sessionUpdate":"tool_call","status":"pending","title":"Read File","toolCallId":"toolu_sub_read"}}}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"capabilities":{},"name":"Summarize","prompt":"Summarize the repo","sessionUpdate":"subagent_spawned","subagentSessionId":"agent_t","task":"Summarize the repo"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_t","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_transcript"}},"configOptions":[{"category":"model","currentValue":"claude-sonnet-4-6","id":"model","name":"Model","options":[{"name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_t","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_transcript"}},"content":{"text":"Look at t","type":"text"},"messageId":"msg_transcript_sub","sessionUpdate":"agent_thought_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_t","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_transcript"}},"content":{"text":"he layout.","type":"text"},"messageId":"msg_transcript_sub","sessionUpdate":"agent_thought_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_t","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_transcript"}},"content":{"text":"The repo has ","type":"text"},"messageId":"msg_transcript_sub","sessionUpdate":"agent_message_chunk"}}}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"a2","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"Review the diff","type":"text"}],"messageId":"toolu_agent","recipientMessageId":"toolu_agent","recipientSessionId":"a2","senderSessionId":"<session>","sessionUpdate":"session_message"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"a2","update":{"content":[{"text":"Review the diff","type":"text"}],"messageId":"toolu_agent","recipientSessionId":"a2","senderMessageId":"toolu_agent","senderSessionId":"<session>","sessionUpdate":"session_message"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"a2","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_agent"}},"configOptions":[{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","name":"Model","options":[{"name":"claude-sonnet-4-6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"a2","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_agent"}},"configOptions":[{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","name":"Model","options":[{"name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"a2","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_agent"}},"content":{"text":"Reviewi","type":"text"},"messageId":"msg_async_sub","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"a2","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_agent"}},"content":{"text":"ng now.","type":"text"},"messageId":"msg_async_sub","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionId":"a2","sessionUpdate":"subagent_update","state":{"state":"idle","stopReason":"end_turn"}}}}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_late","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"Check logs","type":"text"}],"messageId":"toolu_late","recipientMessageId":"toolu_late","recipientSessionId":"agent_late","senderSessionId":"<session>","sessionUpdate":"session_message"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_late","update":{"content":[{"text":"Check logs","type":"text"}],"messageId":"toolu_late","recipientSessionId":"agent_late","senderMessageId":"toolu_late","senderSessionId":"<session>","sessionUpdate":"session_message"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_late","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_late"}},"configOptions":[{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","name":"Model","options":[{"name":"claude-sonnet-4-6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_late","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_late"}},"configOptions":[{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","name":"Model","options":[{"name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_late","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_late"}},"content":[],"kind":"read","locations":[],"name":"Read","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Read File","toolCallId":"toolu_late_read"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_late","update":{"locations":[{"line":1,"path":"/var/log/app.log"}],"rawInput":{"file_path":"/var/log/app.log"},"sessionUpdate":"tool_call_update","title":"Read /var/log/app.log","toolCallId":"toolu_late_read"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"The logs are clean.","type":"text"}],"messageId":"toolu_late:reply","recipientSessionId":"<session>","senderSessionId":"agent_late","sessionUpdate":"session_message"}}}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_n","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"Make the build pass","type":"text"}],"messageId":"toolu_native","recipientMessageId":"toolu_native","recipientSessionId":"agent_n","senderSessionId":"<session>","sessionUpdate":"session_message"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_n","update":{"content":[{"text":"Make the build pass","type":"text"}],"messageId":"toolu_native","recipientSessionId":"agent_n","senderMessageId":"toolu_native","senderSessionId":"<session>","sessionUpdate":"session_message"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_n","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_native"}},"configOptions":[{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","name":"Model","options":[{"name":"claude-sonnet-4-6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_n","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_native"}},"configOptions":[{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","name":"Model","options":[{"name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_n","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_native"}},"content":{"text":"Running t","type":"text"},"messageId":"msg_native_sub_1","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_n","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_native"}},"content":{"text":"he build.","type":"text"},"messageId":"msg_native_sub_1","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_n","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_native"}},"content":[],"kind":"search","name":"Grep","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"grep","toolCallId":"toolu_native_grep"}}}
|
||||
|
||||
@@ -9,12 +9,12 @@
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_outer","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"Plan it","type":"text"}],"messageId":"toolu_outer","recipientMessageId":"toolu_outer","recipientSessionId":"agent_outer","senderSessionId":"<session>","sessionUpdate":"session_message"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_outer","update":{"content":[{"text":"Plan it","type":"text"}],"messageId":"toolu_outer","recipientSessionId":"agent_outer","senderMessageId":"toolu_outer","senderSessionId":"<session>","sessionUpdate":"session_message"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_outer","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_outer"}},"configOptions":[{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","name":"Model","options":[{"name":"claude-sonnet-4-6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_outer","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_outer"}},"configOptions":[{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","name":"Model","options":[{"name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_outer","update":{"capabilities":{"cancel":{}},"description":"Read the spec","sessionId":"agent_inner","sessionUpdate":"subagent_update","state":{"state":"running"},"title":"Explore"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_inner","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_outer","update":{"content":[{"text":"Read spec.md","type":"text"}],"messageId":"toolu_inner","recipientMessageId":"toolu_inner","recipientSessionId":"agent_inner","senderSessionId":"agent_outer","sessionUpdate":"session_message"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_inner","update":{"content":[{"text":"Read spec.md","type":"text"}],"messageId":"toolu_inner","recipientSessionId":"agent_inner","senderMessageId":"toolu_inner","senderSessionId":"agent_outer","sessionUpdate":"session_message"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_inner","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_inner"}},"configOptions":[{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","name":"Model","options":[{"name":"claude-sonnet-4-6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_inner","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_inner"}},"configOptions":[{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","name":"Model","options":[{"name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_inner","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_inner"}},"content":[],"kind":"read","locations":[],"name":"Read","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Read File","toolCallId":"toolu_grandchild_read"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_inner","update":{"locations":[{"line":1,"path":"/spec.md"}],"rawInput":{"file_path":"/spec.md"},"sessionUpdate":"tool_call_update","title":"Read /spec.md","toolCallId":"toolu_grandchild_read"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_inner","update":{"content":[{"content":{"text":"```\n1\tspec\n```","type":"text"},"type":"content"}],"rawOutput":"1\tspec","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_grandchild_read"}}}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_1","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"Find the parser","type":"text"}],"messageId":"toolu_task","recipientMessageId":"toolu_task","recipientSessionId":"agent_1","senderSessionId":"<session>","sessionUpdate":"session_message"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_1","update":{"content":[{"text":"Find the parser","type":"text"}],"messageId":"toolu_task","recipientSessionId":"agent_1","senderMessageId":"toolu_task","senderSessionId":"<session>","sessionUpdate":"session_message"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_1","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task"}},"configOptions":[{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","name":"Model","options":[{"name":"claude-sonnet-4-6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_1","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task"}},"configOptions":[{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","name":"Model","options":[{"name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_1","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task"}},"content":{"text":"Looking for","type":"text"},"messageId":"msg_sub_1","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_1","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task"}},"content":{"text":" the parser.","type":"text"},"messageId":"msg_sub_1","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_1","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task"}},"content":[],"kind":"read","locations":[],"name":"Read","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Read File","toolCallId":"toolu_sub_read"}}}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_t","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"Summarize the repo","type":"text"}],"messageId":"toolu_transcript","recipientMessageId":"toolu_transcript","recipientSessionId":"agent_t","senderSessionId":"<session>","sessionUpdate":"session_message"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_t","update":{"content":[{"text":"Summarize the repo","type":"text"}],"messageId":"toolu_transcript","recipientSessionId":"agent_t","senderMessageId":"toolu_transcript","senderSessionId":"<session>","sessionUpdate":"session_message"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_t","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_transcript"}},"configOptions":[{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","name":"Model","options":[{"name":"claude-sonnet-4-6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_t","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_transcript"}},"configOptions":[{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","name":"Model","options":[{"name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_t","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_transcript"}},"content":{"text":"Look at t","type":"text"},"messageId":"msg_transcript_sub","sessionUpdate":"agent_thought_chunk"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_t","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_transcript"}},"content":{"text":"he layout.","type":"text"},"messageId":"msg_transcript_sub","sessionUpdate":"agent_thought_chunk"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_t","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_transcript"}},"content":{"text":"The repo has ","type":"text"},"messageId":"msg_transcript_sub","sessionUpdate":"agent_message_chunk"}}}
|
||||
|
||||
@@ -1137,7 +1137,16 @@ describe("ACP v2 prompts", () => {
|
||||
// The child's past `running` does not replay; its last state goes once,
|
||||
// after the replay. That state is history to the client, so the child's
|
||||
// current state follows the response: its process is gone, so it is idle.
|
||||
// The child's model, from its transcript, goes right after its
|
||||
// announcement. It does not go again after the response: it is unchanged.
|
||||
const model = expect.objectContaining({
|
||||
sessionUpdate: "config_option_update",
|
||||
configOptions: [
|
||||
expect.objectContaining({ configId: "model", currentValue: "claude-haiku-4-5" }),
|
||||
],
|
||||
});
|
||||
expect(child).toEqual([
|
||||
model,
|
||||
expect.objectContaining({ sessionUpdate: "session_message", content: text("Look around") }),
|
||||
// The client may still hold the child's message, so the replay clears it.
|
||||
{ sessionUpdate: "agent_message", messageId: "msg_child", content: [] },
|
||||
|
||||
@@ -294,6 +294,24 @@ describe("createSession options merging", () => {
|
||||
.at(-1)
|
||||
?.every((option) => option.type === "select" && option.options.length === 1),
|
||||
).toBe(true);
|
||||
// A model of the root session's list goes as that list's entry, with its name.
|
||||
const rootModel = session.configOptions.find((option) => option.id === "model");
|
||||
const listed =
|
||||
rootModel?.type === "select"
|
||||
? (rootModel.options as Array<{ value: string; name: string }>).find(
|
||||
(entry) => entry.value !== "default",
|
||||
)
|
||||
: undefined;
|
||||
expect(listed).toBeDefined();
|
||||
await invoke("PostModelSwitch", { agent_id: "child", to_model: listed!.value, source: "auto" });
|
||||
expect(
|
||||
configs()
|
||||
.at(-1)
|
||||
?.find((option) => option.id === "model"),
|
||||
).toMatchObject({
|
||||
currentValue: listed!.value,
|
||||
options: [{ value: listed!.value, name: listed!.name }],
|
||||
});
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
expect(session.configOptions).toEqual(parentOptions);
|
||||
expect(
|
||||
@@ -303,6 +321,77 @@ describe("createSession options merging", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("reports an AIR child's model switch under its current generation", async () => {
|
||||
const updates: AcpSessionNotification[] = [];
|
||||
const client = createMockClient();
|
||||
client.sessionUpdate = async (notification) => {
|
||||
updates.push(notification);
|
||||
};
|
||||
const observedAgent = new ClaudeAcpAgent(client);
|
||||
await observedAgent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: {
|
||||
_meta: { jetbrains: { air: { version: 1, capabilities: ["nativeSubagentSessions"] } } },
|
||||
} as ClientCapabilities,
|
||||
});
|
||||
const { sessionId } = await observedAgent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
const session = observedAgent.sessions[sessionId]!;
|
||||
const runtime = (session.nativeSubagentRuntime = new NativeSubagentRuntime(
|
||||
"air",
|
||||
sessionId,
|
||||
session,
|
||||
(notification) => client.sessionUpdate(notification),
|
||||
{ log: () => {} },
|
||||
));
|
||||
const matcher = capturedOptions!.hooks!.PostModelSwitch!.find((matcher) => !matcher.matcher);
|
||||
const switchModel = async (fields: Record<string, unknown>) => {
|
||||
for (const hook of matcher!.hooks) {
|
||||
await hook(
|
||||
{
|
||||
hook_event_name: "PostModelSwitch",
|
||||
session_id: sessionId,
|
||||
cwd: process.cwd(),
|
||||
transcript_path: "/tmp/transcript",
|
||||
source: "auto",
|
||||
...fields,
|
||||
} as any,
|
||||
undefined,
|
||||
{ signal: new AbortController().signal },
|
||||
);
|
||||
}
|
||||
};
|
||||
const deliver = async () => {};
|
||||
await runtime.taskStarted({ taskId: "child", subagentType: "Explore" }, deliver);
|
||||
await runtime.finishTask("child", "completed", deliver);
|
||||
await runtime.taskResumed("child", deliver, "Continue");
|
||||
// The hook names the SDK agent id; AIR's draft runs the resumed agent in a new session.
|
||||
await switchModel({ agent_id: "child", to_model: "claude-sonnet-4-6" });
|
||||
const rootModel = session.configOptions.find((option) => option.id === "model");
|
||||
const rootEntryName =
|
||||
rootModel?.type === "select"
|
||||
? (rootModel.options as Array<{ value: string; name: string }>).find(
|
||||
(entry) => entry.value === "claude-sonnet-4-6",
|
||||
)?.name
|
||||
: undefined;
|
||||
expect(rootEntryName).toBeDefined();
|
||||
const models = updates.flatMap(({ sessionId: target, update }) =>
|
||||
update.sessionUpdate === "config_option_update"
|
||||
? update.configOptions.map((option) => [target, option.currentValue, option])
|
||||
: [],
|
||||
);
|
||||
expect(models).toEqual([
|
||||
[
|
||||
"child:generation:2",
|
||||
"claude-sonnet-4-6",
|
||||
expect.objectContaining({
|
||||
id: "model",
|
||||
// The name of the root session's entry for the model.
|
||||
options: [{ value: "claude-sonnet-4-6", name: rootEntryName }],
|
||||
}),
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it("merges user-provided disallowedTools with ACP internal list", async () => {
|
||||
await agent.newSession({
|
||||
cwd: process.cwd(),
|
||||
|
||||
@@ -2,11 +2,13 @@ import { describe, it, expect } from "vitest";
|
||||
import { randomUUID } from "crypto";
|
||||
import { query } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { SessionConfigOption } from "@agentclientprotocol/sdk/unstable";
|
||||
import {
|
||||
resolveModelPreference,
|
||||
applyAvailableModelsAllowlist,
|
||||
isDeniedModel,
|
||||
matchResumedModel,
|
||||
modelEntryFor,
|
||||
} from "../session-model.js";
|
||||
import { effortFlagSettings, settingsEffortForModel } from "../session-effort.js";
|
||||
|
||||
@@ -392,3 +394,103 @@ describe("effortFlagSettings", () => {
|
||||
expect(effortFlagSettings(undefined, {})).toEqual({ effortLevel: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe("modelEntryFor", () => {
|
||||
const infos: ModelInfo[] = [
|
||||
{
|
||||
value: "default",
|
||||
resolvedModel: "claude-opus-4-6[1m]",
|
||||
displayName: "Default (recommended)",
|
||||
description: "",
|
||||
},
|
||||
{ value: "opus", resolvedModel: "claude-opus-4-6", displayName: "Opus 4.6", description: "" },
|
||||
{
|
||||
value: "opus[1m]",
|
||||
resolvedModel: "claude-opus-4-6[1m]",
|
||||
displayName: "Opus 4.6 (1M context)",
|
||||
description: "",
|
||||
},
|
||||
{
|
||||
value: "haiku",
|
||||
resolvedModel: "claude-haiku-4-5",
|
||||
displayName: "Haiku 4.5",
|
||||
description: "",
|
||||
},
|
||||
];
|
||||
/** The session's `model` option with `current` selected. */
|
||||
const options = (current: string): SessionConfigOption[] => [
|
||||
{
|
||||
id: "model",
|
||||
name: "Model",
|
||||
category: "model",
|
||||
type: "select",
|
||||
currentValue: current,
|
||||
options: infos.map((info) => ({ value: info.value, name: info.displayName })),
|
||||
},
|
||||
];
|
||||
const entryOf = (current: string, model: string) => modelEntryFor(options(current), infos, model);
|
||||
|
||||
it("prefers the entry that the user picked when it runs the model up to a context hint", () => {
|
||||
// A response reports the model id without the context hint.
|
||||
expect(entryOf("opus[1m]", "claude-opus-4-6")).toEqual({
|
||||
value: "opus[1m]",
|
||||
name: "Opus 4.6 (1M context)",
|
||||
});
|
||||
expect(entryOf("default", "claude-opus-4-6")).toEqual({
|
||||
value: "default",
|
||||
name: "Default (recommended)",
|
||||
});
|
||||
});
|
||||
|
||||
it("takes the listed entry of another model", () => {
|
||||
expect(entryOf("opus[1m]", "claude-haiku-4-5")).toEqual({ value: "haiku", name: "Haiku 4.5" });
|
||||
expect(entryOf("haiku", "claude-opus-4-6")).toEqual({ value: "opus", name: "Opus 4.6" });
|
||||
// A concrete entry comes before `default` when both run the model.
|
||||
expect(entryOf("haiku", "claude-opus-4-6[1m]")).toEqual({
|
||||
value: "opus[1m]",
|
||||
name: "Opus 4.6 (1M context)",
|
||||
});
|
||||
});
|
||||
|
||||
it("compares a dated or a provider model id as it is", () => {
|
||||
// `resolvedModel` is the wire id that a response reports, date and provider prefix included.
|
||||
const dated: ModelInfo[] = [
|
||||
{
|
||||
value: "haiku",
|
||||
resolvedModel: "claude-haiku-4-5-20251001",
|
||||
displayName: "Haiku 4.5",
|
||||
description: "",
|
||||
},
|
||||
{
|
||||
value: "sonnet",
|
||||
resolvedModel: "eu.anthropic.claude-sonnet-5",
|
||||
displayName: "Sonnet 5",
|
||||
description: "",
|
||||
},
|
||||
];
|
||||
const datedOptions: SessionConfigOption[] = [
|
||||
{
|
||||
id: "model",
|
||||
name: "Model",
|
||||
category: "model",
|
||||
type: "select",
|
||||
currentValue: "sonnet",
|
||||
options: dated.map((info) => ({ value: info.value, name: info.displayName })),
|
||||
},
|
||||
];
|
||||
expect(modelEntryFor(datedOptions, dated, "claude-haiku-4-5-20251001")).toEqual({
|
||||
value: "haiku",
|
||||
name: "Haiku 4.5",
|
||||
});
|
||||
expect(modelEntryFor(datedOptions, dated, "eu.anthropic.claude-sonnet-5")).toEqual({
|
||||
value: "sonnet",
|
||||
name: "Sonnet 5",
|
||||
});
|
||||
expect(modelEntryFor(datedOptions, dated, "claude-haiku-4-5")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("matches no entry for a model that the list does not have", () => {
|
||||
expect(entryOf("opus", "claude-sonnet-4-6")).toBeUndefined();
|
||||
expect(entryOf("opus", "claude-opus-4-7")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
resumedNativeSubagentId,
|
||||
sendMessageResume,
|
||||
sendMessageResumePrompt,
|
||||
type SubagentModels,
|
||||
} from "../native-subagents.js";
|
||||
|
||||
function deferred(): { promise: Promise<void>; resolve: () => void } {
|
||||
@@ -1059,16 +1060,13 @@ describe("NativeSubagentRuntime in the RFD form", () => {
|
||||
expect(published.map(({ update }) => update.sessionUpdate)).toEqual(["subagent_update"]);
|
||||
});
|
||||
|
||||
it.each(["air", undefined] as const)(
|
||||
"does not report child config in the %s form",
|
||||
async (form) => {
|
||||
const deliver = vi.fn(async () => {});
|
||||
const runtime = new NativeSubagentRuntime(form, "root", {}, deliver, { log: () => {} });
|
||||
await runtime.reportModel("launch-1", "child-model", deliver);
|
||||
await runtime.reportConfiguration("worker-1", [effortOption("high")]);
|
||||
expect(deliver).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
it("does not report child config without native subagent sessions", async () => {
|
||||
const deliver = vi.fn(async () => {});
|
||||
const runtime = new NativeSubagentRuntime(undefined, "root", {}, deliver, { log: () => {} });
|
||||
await runtime.reportModel("launch-1", "child-model", deliver);
|
||||
await runtime.reportConfiguration("worker-1", [effortOption("high")]);
|
||||
expect(deliver).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("hides the Agent tool call, which the parent's session messages replace", async () => {
|
||||
const { runtime, control } = await runningWorker();
|
||||
@@ -2470,3 +2468,170 @@ describe("NativeSubagentRuntime in the AIR form, for an agent from before a load
|
||||
expect(spawned(published)).toEqual(["root -> worker-1"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("NativeSubagentRuntime child models", () => {
|
||||
const entries = {
|
||||
default: { value: "default", name: "Default (recommended)" },
|
||||
sonnet: { value: "sonnet", name: "Sonnet 4.6" },
|
||||
};
|
||||
/** The root session's `model` option, as `modelEntryFor` maps a model id to it. */
|
||||
const models: SubagentModels = {
|
||||
entry: (model) => (model === "claude-sonnet-4-6" ? entries.sonnet : undefined),
|
||||
name: (model) => (model === "claude-opus-5" ? "Opus 5" : undefined),
|
||||
};
|
||||
const agentCall = (toolCallId: string, input: Record<string, unknown>): AcpSessionNotification =>
|
||||
({
|
||||
sessionId: "root",
|
||||
update: {
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId,
|
||||
title: "Agent",
|
||||
status: "pending",
|
||||
rawInput: { description: "Fix the build", prompt: "Make it pass", ...input },
|
||||
_meta: {
|
||||
claudeCode: { toolName: "Agent" },
|
||||
jetbrains: { air: { version: 1, subagent: true } },
|
||||
},
|
||||
},
|
||||
}) as AcpSessionNotification;
|
||||
|
||||
/** A runtime of `form` whose child `worker-1` the call `launch-1` with `input` launched. */
|
||||
async function spawn(form: "rfd" | "air", input: Record<string, unknown> = {}) {
|
||||
const published: AcpSessionNotification[] = [];
|
||||
const runtime = new NativeSubagentRuntime(
|
||||
form,
|
||||
"root",
|
||||
{},
|
||||
async (notification) => {
|
||||
published.push(notification);
|
||||
},
|
||||
{ log: () => {} },
|
||||
undefined,
|
||||
undefined,
|
||||
models,
|
||||
);
|
||||
const deliver = async (notification: AcpSessionNotification) => {
|
||||
const routed = await runtime.route(notification, deliver);
|
||||
if (routed) published.push(routed);
|
||||
};
|
||||
await deliver(agentCall("launch-1", input));
|
||||
await runtime.taskStarted(
|
||||
{ taskId: "worker-1", toolUseId: "launch-1", subagentType: "Explore" },
|
||||
deliver,
|
||||
);
|
||||
return { runtime, published, deliver };
|
||||
}
|
||||
/** Each child `model` option sent: its session, value, and name, the only choice. */
|
||||
const modelUpdates = (published: AcpSessionNotification[]) =>
|
||||
published.flatMap(({ sessionId, update }) =>
|
||||
update.sessionUpdate === "config_option_update"
|
||||
? update.configOptions.map((option) => {
|
||||
const choices = option.type === "select" ? option.options : [];
|
||||
expect(option).toMatchObject({ id: "model", category: "model", type: "select" });
|
||||
expect(choices).toEqual([{ value: option.currentValue, name: expect.any(String) }]);
|
||||
return [sessionId, option.currentValue, (choices[0] as { name: string }).name];
|
||||
})
|
||||
: [],
|
||||
);
|
||||
|
||||
it.each(["rfd", "air"] as const)(
|
||||
"%s: sends no model before the child's first response, whatever the launch asks for",
|
||||
async (form) => {
|
||||
const { runtime, published, deliver } = await spawn(form, { model: "sonnet" });
|
||||
expect(modelUpdates(published)).toEqual([]);
|
||||
await runtime.reportModel("launch-1", "<synthetic>", deliver);
|
||||
expect(modelUpdates(published)).toEqual([]);
|
||||
await runtime.reportModel("launch-1", "claude-sonnet-4-6", deliver);
|
||||
expect(modelUpdates(published)).toEqual([["worker-1", "sonnet", "Sonnet 4.6"]]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["rfd", "air"] as const)(
|
||||
"%s: sends the model again only when the entry changes",
|
||||
async (form) => {
|
||||
const { runtime, published, deliver } = await spawn(form);
|
||||
await runtime.reportModel("launch-1", "claude-sonnet-4-6", deliver);
|
||||
await runtime.reportModel("launch-1", "claude-sonnet-4-6", deliver);
|
||||
// The root session's own responses do not change the child.
|
||||
await runtime.reportModel(null, "claude-opus-5", deliver);
|
||||
expect(modelUpdates(published)).toEqual([["worker-1", "sonnet", "Sonnet 4.6"]]);
|
||||
// A model id that matches no entry goes as the id, with its display name.
|
||||
await runtime.reportModel("launch-1", "claude-opus-5", deliver);
|
||||
await runtime.reportModel("launch-1", "claude-opus-5", deliver);
|
||||
expect(modelUpdates(published)).toEqual([
|
||||
["worker-1", "sonnet", "Sonnet 4.6"],
|
||||
["worker-1", "claude-opus-5", "Opus 5"],
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it("air: drops a model update that waits for an earlier one when the child ends", async () => {
|
||||
const published: AcpSessionNotification[] = [];
|
||||
const firstSent = deferred();
|
||||
const runtime = new NativeSubagentRuntime(
|
||||
"air",
|
||||
"root",
|
||||
{},
|
||||
async (notification) => {
|
||||
published.push(notification);
|
||||
// The client takes the first model update slowly.
|
||||
if (notification.update.sessionUpdate === "config_option_update") await firstSent.promise;
|
||||
},
|
||||
{ log: () => {} },
|
||||
undefined,
|
||||
undefined,
|
||||
models,
|
||||
);
|
||||
const deliver = async (notification: AcpSessionNotification) => {
|
||||
const routed = await runtime.route(notification, deliver);
|
||||
if (routed) published.push(routed);
|
||||
};
|
||||
await deliver(agentCall("launch-1", {}));
|
||||
await runtime.taskStarted(
|
||||
{ taskId: "worker-1", toolUseId: "launch-1", subagentType: "Explore" },
|
||||
deliver,
|
||||
);
|
||||
const first = runtime.reportModel("launch-1", "claude-sonnet-4-6", deliver);
|
||||
await vi.waitFor(() => expect(modelUpdates(published)).toHaveLength(1));
|
||||
// The child ends while the client has not taken the first update yet, and
|
||||
// a model update comes in meanwhile.
|
||||
const finished = runtime.finishTask("worker-1", "completed", deliver, "launch-1");
|
||||
const second = runtime.reportModel("launch-1", "claude-opus-5", deliver);
|
||||
firstSent.resolve();
|
||||
await Promise.all([first, second, finished]);
|
||||
// Nothing follows the end.
|
||||
expect(published.at(-1)?.update.sessionUpdate).toBe("subagent_state_update");
|
||||
});
|
||||
|
||||
it("air: reports a hook's configuration under the agent's current generation", async () => {
|
||||
const { runtime, published, deliver } = await spawn("air");
|
||||
await runtime.finishTask("worker-1", "completed", deliver, "launch-1");
|
||||
// The SDK resumes the agent: AIR's draft opens a new generation session.
|
||||
await runtime.taskResumed("worker-1", deliver, "Continue");
|
||||
const generation = published
|
||||
.flatMap(({ update }) =>
|
||||
update.sessionUpdate === "subagent_spawned" ? [update.subagentSessionId] : [],
|
||||
)
|
||||
.at(-1);
|
||||
expect(generation).toBe("worker-1:generation:2");
|
||||
// A hook names the SDK agent id, not the generation session.
|
||||
await runtime.reportConfiguration("worker-1", [
|
||||
{
|
||||
id: "model",
|
||||
name: "Model",
|
||||
category: "model",
|
||||
type: "select",
|
||||
currentValue: "sonnet",
|
||||
options: [{ value: "sonnet", name: "Sonnet 4.6" }],
|
||||
},
|
||||
]);
|
||||
expect(modelUpdates(published)).toEqual([[generation, "sonnet", "Sonnet 4.6"]]);
|
||||
});
|
||||
|
||||
it("air: sends no model to a child after its end", async () => {
|
||||
const { runtime, published, deliver } = await spawn("air");
|
||||
await runtime.finishTask("worker-1", "completed", deliver, "launch-1");
|
||||
await runtime.reportModel("launch-1", "claude-sonnet-4-6", deliver);
|
||||
expect(modelUpdates(published)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user