fix: do not hold an AIR load replay until the CLI starts (#1433)

## Summary

#1432 made an AIR `session/load` replay wait for the CLI start before it
sent the children's models. So the end of every such replay
(`loadReplayEnd` and the children's `subagent_state_update`) came only
after the CLI started, also for a history without subagents.

- The replay no longer waits. It sends each child's model as the replay
reaches it. During a load the session's model list is not known yet, so
the model goes as its id.
- After the load response, each replayed child whose model the list maps
to another entry gets that entry. This also applies to a child that the
replay already ended. It is a more exact snapshot of the same
configuration.
- `modelEntryFor` and `modelDisplayName` keep their results per model
list. `reportModel` skips a model that the child already has.
- The buffer of a child that is not announced yet keeps only its latest
configuration update, so model reports do not crowd out the child's
output.
- AIR: the runtime forgets the configuration of an ended child.

## Validation

- `npm run build`, `npm run check`
- `npm run test:run`, two runs: 3321 passed, 30 skipped.
- `src/tests/acp-scenarios.test.ts`, 30 runs in 6 parallel workers: no
failure.
- New tests: the load end event before the CLI start, with and without
subagents (both fail on `main`); the post-load refinement of an ended
child; the buffer; the cleanup; the memo after a list change.
- Microbenchmark of 10,000 child responses over 200 children, with 51
models:

  | | before | after |
  |---|---|---|
  | `modelEntryFor`, last entry | 12.16 µs | 0.23 µs |
  | `modelEntryFor`, no match | 64.60 µs | 0.08 µs |
  | `reportModel`, AIR | 13.66 µs | 4.03 µs |
  | `reportModel`, RFD | 12.80 µs | 3.23 µs |

## Related

- Follow-up to
https://github.com/agentclientprotocol/claude-agent-acp/pull/1432
- RFD change:
https://github.com/agentclientprotocol/agent-client-protocol/pull/2482
- JetBrains issue:
[IJAI-4754](https://youtrack.jetbrains.com/issue/IJAI-4754)
This commit is contained in:
nikita-ashihmin
2026-10-25 23:05:36 -07:00
committed by GitHub
parent c319e5e542
commit eb9d63e5fd
8 changed files with 321 additions and 76 deletions

View File

@@ -122,8 +122,10 @@ import {
NativeSubagent,
NativeSubagentRuntime,
nativeSubagentState,
type ModelEntry,
responseModelEntry,
resumedNativeSubagentId,
sameModelEntry,
sendMessageResume,
sendMessageResumePrompt,
type SubagentModels,
@@ -986,8 +988,6 @@ 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;
};
@@ -2728,6 +2728,15 @@ export class ClaudeAcpAgent {
* {@link publishCurrentSubagentStates} sends its children's current states.
*/
private readonly replayedSubagents = new Map<string, RfdSubagentReplay>();
/**
* AIR's draft: the model that the last load of each session replayed to each
* child session, as the model id and the entry that went out. See
* {@link publishReplayedChildModels}.
*/
private readonly replayedChildModels = new Map<
string,
Map<string, { model: string; sent: ModelEntry }>
>();
/** See the `foreground` constructor option. */
private readonly foregroundEvents?: (sessionId: string) => ForegroundEvents;
/**
@@ -2806,6 +2815,7 @@ export class ClaudeAcpAgent {
clearHookCallbacks(id);
session.nativeSubagentRuntime?.clear();
session.asyncTaskRuntime?.clear();
this.replayedChildModels.delete(id);
if (this.sessions[id] === session) delete this.sessions[id];
},
settleCancelledTurn: (original, session, turn) => {
@@ -3075,8 +3085,6 @@ 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))
@@ -3102,11 +3110,10 @@ export class ClaudeAcpAgent {
);
} catch (error) {
pending.stopped = true;
sessionCreated();
await replay;
this.replayedChildModels.delete(params.sessionId);
throw error;
}
sessionCreated();
timing.phase("session-ready");
const replayed = await replay;
if (!replayed.ok) throw replayed.error;
@@ -8505,6 +8512,7 @@ export class ClaudeAcpAgent {
clearHookCallbacks(params.sessionId);
session.nativeSubagentRuntime?.clear();
session.asyncTaskRuntime?.clear();
this.replayedChildModels.delete(params.sessionId);
delete this.sessions[params.sessionId];
} else {
this.logger.error(`Session ${params.sessionId}: query stream error: ${message}`);
@@ -9083,6 +9091,7 @@ export class ClaudeAcpAgent {
clearHookCallbacks(sessionId);
session.nativeSubagentRuntime?.clear();
session.asyncTaskRuntime?.clear();
this.replayedChildModels.delete(sessionId);
delete this.sessions[sessionId];
this.sessionIndex.onTeardown(sessionId);
const ended = await raceTimeoutAndAbort(
@@ -9299,9 +9308,15 @@ export class ClaudeAcpAgent {
notifiedState?: SubagentState;
/** The model id of the child's latest response in its transcript. */
responseModel?: string;
/** The `model` option entry that the replay sent to the child. */
model?: ModelEntry;
}
>();
const replayModels = this.subagentModels(sessionId);
// A load replays while the CLI starts, and the session's model list comes
// with the CLI at an unknown point of the replay. So a pending replay maps
// no model to the list, and each child's model is its id until the load
// response refines it (see `publishCurrentSubagentStates`).
const replayModels = pending ? undefined : this.subagentModels(sessionId);
// Registers the Agent and Task launches of `list`, and the terminal state
// of each launch from its tool_result.
@@ -9562,10 +9577,21 @@ export class ClaudeAcpAgent {
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.
// AIR's draft: the entry of the model list that is known now. A load
// can lack the list until the CLI started, so the entry goes again
// after the load response when the list maps it to another one.
const replayChild = replayChildren.get(parentToolUseId);
if (replayChild && model && model !== "<synthetic>") {
const entry = responseModelEntry(model, replayModels);
if (replayChild?.announced && entry && !sameModelEntry(replayChild.model, entry)) {
replayChild.responseModel = model;
replayChild.model = entry;
await this.client.sessionUpdate({
sessionId: replayChild.sessionId,
update: {
sessionUpdate: "config_option_update",
configOptions: [childModelOption(entry)],
},
});
}
}
}
@@ -9718,24 +9744,14 @@ 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) {
const childModels = new Map<string, { model: string; sent: ModelEntry }>();
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)],
},
});
if (child.responseModel && child.model) {
childModels.set(child.sessionId, { model: child.responseModel, sent: child.model });
}
}
if (childModels.size > 0) this.replayedChildModels.set(sessionId, childModels);
// 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
@@ -10785,6 +10801,7 @@ export class ClaudeAcpAgent {
* as history.
*/
async publishCurrentSubagentStates(sessionId: string): Promise<void> {
await this.publishReplayedChildModels(sessionId);
const replay = this.replayedSubagents.get(sessionId);
if (!replay) return;
this.replayedSubagents.delete(sessionId);
@@ -10801,6 +10818,36 @@ export class ClaudeAcpAgent {
}
}
/**
* AIR's draft: a load replays while the CLI starts, so the replay can lack
* the session's model list and send a child's model as its id. After the
* load response the list is known, and each replayed child whose model the
* list maps to another entry gets that entry. The replay already ended the
* child, and AIR's draft allows no other update after a child's end, but it
* takes this more exact snapshot of the same configuration.
*/
private async publishReplayedChildModels(sessionId: string): Promise<void> {
const childModels = this.replayedChildModels.get(sessionId);
if (!childModels) return;
this.replayedChildModels.delete(sessionId);
const models = this.subagentModels(sessionId);
try {
for (const [childSessionId, { model, sent }] of childModels) {
const entry = responseModelEntry(model, models);
if (!entry || sameModelEntry(entry, sent)) continue;
await this.client.sessionUpdate({
sessionId: childSessionId,
update: {
sessionUpdate: "config_option_update",
configOptions: [childModelOption(entry)],
},
});
}
} catch (error) {
this.logger.error(`Session ${sessionId}: failed to send subagent models:`, error);
}
}
private async updateConfigOption(
sessionId: string,
configId: string,

View File

@@ -281,14 +281,23 @@ export class NativeSubagentRuntime {
model: string | undefined,
deliver: Publish,
): Promise<void> {
if (!this.form || !parentToolUseId || !model || model === "<synthetic>") {
return;
const entry = this.form && parentToolUseId ? responseModelEntry(model, this.models) : undefined;
if (!entry) return;
// Every response of a child reports its model: most repeat the model that
// the client already has, and need no routing.
const child = this.childByParentToolUse.get(parentToolUseId!);
const current = child?.announced
? this.configBySession.get(child.sessionId)?.options.get(MODEL_CONFIG_ID)
: undefined;
if (current?.type === "select" && current.currentValue === entry.value) {
const shown = current.options[0];
if (shown && "name" in shown && shown.name === entry.name) return;
}
await deliver({
sessionId: this.rootSessionId,
update: {
sessionUpdate: "config_option_update",
configOptions: [childModelOption(responseModelEntry(model, this.models)!)],
configOptions: [childModelOption(entry)],
_meta: { claudeCode: { parentToolUseId } },
},
});
@@ -316,6 +325,14 @@ export class NativeSubagentRuntime {
// `<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;
// AIR's draft takes no configuration of an ended child (see `finishTask`).
if (
this.form === "air" &&
known &&
(known.terminalState !== undefined || known.terminalPromise)
) {
return;
}
let config = this.configBySession.get(sessionId);
if (!config) {
const pending = [...this.configBySession.keys()].filter(
@@ -718,6 +735,8 @@ export class NativeSubagentRuntime {
if (child.parentToolUseId) (child.endedRuns ??= new Set()).add(child.parentToolUseId);
} else {
await finishNativeSubagent(this.session, taskId, state, this.publish);
// AIR's draft takes no configuration of an ended child.
this.configBySession.delete(child.sessionId);
}
} finally {
if (child.parentToolUseId) {
@@ -1399,6 +1418,17 @@ export class NativeSubagentRuntime {
private buffer(parentToolUseId: string, notification: AcpSessionNotification): void {
const updates = this.pending.get(parentToolUseId);
// A child reports its model with each response before its announcement.
// Only the latest configuration waits, so they do not fill the buffer.
if (notification.update.sessionUpdate === "config_option_update" && updates) {
const index = updates.findIndex(
({ update }) => update.sessionUpdate === "config_option_update",
);
if (index >= 0) {
updates[index] = notification;
return;
}
}
if (
this.pendingCount >= MAX_PENDING_UPDATES ||
(updates === undefined && this.pending.size >= MAX_PENDING_PARENTS) ||
@@ -1527,6 +1557,11 @@ export function childModelOption(entry: ModelEntry): SessionConfigOption {
};
}
/** Whether two model entries have the same value and name. */
export function sameModelEntry(left: ModelEntry | undefined, right: ModelEntry): boolean {
return left?.value === right.value && left.name === right.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

View File

@@ -609,10 +609,18 @@ export async function getAvailableModels(
/** 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;
let byModel = displayNameCache.get(modelInfos);
if (!byModel) displayNameCache.set(modelInfos, (byModel = new Map()));
if (!byModel.has(model)) {
const info = modelInfos.find((info) => info.value === model || info.resolvedModel === model);
byModel.set(model, info?.displayName);
}
return byModel.get(model);
}
/** {@link modelDisplayName} per model list, which a session replaces and never edits. */
const displayNameCache = new WeakMap<ModelInfo[], Map<string, string | undefined>>();
/** The flat entries of a session's `model` select option. */
function modelOptionEntries(option: SessionConfigOption): Array<{ value: string; name: string }> {
if (option.type !== "select") return [];
@@ -642,6 +650,28 @@ export function modelEntryFor(
configOptions: readonly SessionConfigOption[],
modelInfos: ModelInfo[],
model: string,
): { value: string; name: string } | undefined {
// Each response of a subagent reports its model, so the result is kept per
// list. A session replaces its lists on a change and never edits them.
let byInfos = modelEntryCache.get(configOptions);
if (!byInfos) modelEntryCache.set(configOptions, (byInfos = new WeakMap()));
let byModel = byInfos.get(modelInfos);
if (!byModel) byInfos.set(modelInfos, (byModel = new Map()));
if (byModel.has(model)) return byModel.get(model);
const entry = findModelEntry(configOptions, modelInfos, model);
byModel.set(model, entry);
return entry;
}
const modelEntryCache = new WeakMap<
readonly SessionConfigOption[],
WeakMap<ModelInfo[], Map<string, { value: string; name: string } | undefined>>
>();
function findModelEntry(
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;

View File

@@ -3586,7 +3586,7 @@ 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 () => {
it("refines an ended AIR child's model after the load response, once the model list is known", async () => {
const updates: AcpSessionNotification[] = [];
const agent = new ClaudeAcpAgent(
{
@@ -3596,26 +3596,25 @@ describe("subagent transcript replay", () => {
{ log: () => {}, error: () => {} },
);
await initializeClient(agent, AIR_NATIVE_SUBAGENTS);
vi.mocked(getSessionMessages).mockResolvedValueOnce(replayHistory);
// 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");
await (
agent as unknown as { replaySessionHistory(sessionId: string): Promise<void> }
).replaySessionHistory("s1");
const child = "s1:replay-subagent:parent-agent-call";
const models = () =>
updates.flatMap(({ sessionId, update }) =>
update.sessionUpdate === "config_option_update"
? update.configOptions.map((option) => [
sessionId,
option.currentValue,
(option as { options: Array<{ name: string }> }).options[0]?.name,
])
: [],
);
// Without the list, the replay sends the model id, and then ends the child.
expect(models()).toEqual([[child, "claude-sonnet-4-5", "claude-sonnet-4-5"]]);
expect(updates.at(-1)?.update.sessionUpdate).toBe("subagent_state_update");
(agent.sessions as Record<string, unknown>).s1 = {
configOptions: [
{
@@ -3636,29 +3635,14 @@ describe("subagent transcript replay", () => {
},
],
};
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" }),
}),
await agent.publishCurrentSubagentStates("s1");
expect(models()).toEqual([
[child, "claude-sonnet-4-5", "claude-sonnet-4-5"],
[child, "sonnet", "Sonnet 4.5"],
]);
// Once only.
await agent.publishCurrentSubagentStates("s1");
expect(models()).toHaveLength(2);
});
it("replays a subagent from its own transcript file", async () => {
@@ -3817,13 +3801,13 @@ describe("subagent transcript replay", () => {
[sessionId, "user_message_chunk", "Explore it"],
[sessionId, "subagent_spawned", child1],
[child1, "user_message_chunk", "Look around"],
// The model of the child's response, as the replay reaches it.
[child1, "config_option_update", undefined],
[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],
@@ -4455,10 +4439,10 @@ describe("subagent transcript replay", () => {
const updates = await replay("native");
expect(updates.map(({ sessionId, update }) => [sessionId, update.sessionUpdate])).toEqual([
["s1", "subagent_spawned"],
// The model of its response, as the replay reaches it.
["s1:replay-subagent:parent-agent-call", "config_option_update"],
["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({

View File

@@ -8,9 +8,10 @@
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"terminal_output_delta":{"data":"app.ts","terminal_id":"toolu_r_bash"}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_r_bash"}}}
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"terminal_exit":{"exit_code":0,"signal":null,"terminal_id":"toolu_r_bash"}},"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_r_bash"}}}
{"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":{"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>: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>: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":{"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"}}}

View File

@@ -2158,6 +2158,66 @@ describe("createSession options merging", () => {
await loading;
});
it.each([
["without subagents", [] as Record<string, unknown>[]],
[
"with a subagent",
[
{
type: "assistant",
uuid: "child-message",
session_id: "replay-probe",
parent_tool_use_id: "agent-call",
message: {
id: "api-child-message",
model: "claude-sonnet-4-6",
role: "assistant",
content: [{ type: "text", text: "child report" }],
},
},
],
],
])(
"sends the end event before the CLI start finishes for native subagent sessions (%s)",
async (_label, history) => {
let releaseInit!: () => void;
initGate = new Promise((resolve) => (releaseInit = resolve));
sessionMessages = [userMessage("u1", "first"), ...history];
const updates: SessionNotification[] = [];
const loading = (
await endAgent(updates, undefined, {
_meta: {
jetbrains: {
air: {
version: 1,
capabilities: ["loadReplayEnd", "nativeSubagentSessions"],
},
},
},
} as ClientCapabilities)
).loadSession({ sessionId: "replay-probe", cwd: process.cwd(), mcpServers: [] });
// The child's model goes as the replay reaches it, with no model list yet.
await vi.waitFor(() =>
expect(kinds(updates)).toEqual(
history.length === 0
? ["user_message_chunk", "end"]
: [
"user_message_chunk",
"subagent_spawned",
"config_option_update",
"agent_message_chunk",
"subagent_state_update",
"end",
],
),
);
releaseInit();
await loading;
},
);
it("sends the end event for an empty history", async () => {
sessionMessages = [];
const updates: SessionNotification[] = [];

View File

@@ -452,6 +452,23 @@ describe("modelEntryFor", () => {
});
});
it("maps a model again when the session replaces its option list", () => {
const before = options("haiku");
expect(modelEntryFor(before, infos, "claude-opus-4-6")).toEqual({
value: "opus",
name: "Opus 4.6",
});
// The user picks `opus[1m]`: the session replaces its option list.
expect(modelEntryFor(options("opus[1m]"), infos, "claude-opus-4-6")).toEqual({
value: "opus[1m]",
name: "Opus 4.6 (1M context)",
});
expect(modelEntryFor(before, infos, "claude-opus-4-6")).toEqual({
value: "opus",
name: "Opus 4.6",
});
});
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[] = [

View File

@@ -2628,6 +2628,77 @@ describe("NativeSubagentRuntime child models", () => {
expect(modelUpdates(published)).toEqual([[generation, "sonnet", "Sonnet 4.6"]]);
});
it.each(["rfd", "air"] as const)(
"%s: keeps only the latest model of a child that is not announced yet",
async (form) => {
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);
};
// More responses than the buffer of one parent holds (32), before the launch call.
for (let index = 0; index < 40; index++) {
await runtime.reportModel(
"launch-1",
index % 2 ? "claude-opus-5" : "claude-sonnet-4-6",
deliver,
);
}
await runtime.route(
{
sessionId: "root",
update: {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "working" },
_meta: { claudeCode: { parentToolUseId: "launch-1" } },
},
} as AcpSessionNotification,
deliver,
);
await runtime.taskStarted(
{ taskId: "worker-1", toolUseId: "launch-1", subagentType: "Explore" },
deliver,
);
await deliver(agentCall("launch-1", {}));
expect(modelUpdates(published)).toEqual([["worker-1", "claude-opus-5", "Opus 5"]]);
// The child's output is not crowded out of the buffer.
expect(published.map(({ update }) => update.sessionUpdate)).toContain("agent_message_chunk");
},
);
it("air: forgets the configuration of an ended child", async () => {
const { runtime, published, deliver } = await spawn("air");
await runtime.reportModel("launch-1", "claude-sonnet-4-6", deliver);
expect(runtime.configurationOf("worker-1")).toHaveLength(1);
await runtime.finishTask("worker-1", "completed", deliver, "launch-1");
expect(runtime.configurationOf("worker-1")).toBeUndefined();
// A hook after the end stores nothing either.
await runtime.reportConfiguration("worker-1", [
{
id: "model",
name: "Model",
category: "model",
type: "select",
currentValue: "sonnet",
options: [{ value: "sonnet", name: "Sonnet 4.6" }],
},
]);
expect(runtime.configurationOf("worker-1")).toBeUndefined();
expect(modelUpdates(published)).toEqual([["worker-1", "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");