diff --git a/src/acp-agent.ts b/src/acp-agent.ts index 10e3d87a..a8729bf2 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -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; /** 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(); + /** + * 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 + >(); /** 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 !== "") { + 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(); 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 { + 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 { + 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, diff --git a/src/native-subagents.ts b/src/native-subagents.ts index 7fc02d5d..16333aaa 100644 --- a/src/native-subagents.ts +++ b/src/native-subagents.ts @@ -281,14 +281,23 @@ export class NativeSubagentRuntime { model: string | undefined, deliver: Publish, ): Promise { - if (!this.form || !parentToolUseId || !model || model === "") { - 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 { // `:generation:`, 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 diff --git a/src/session-model.ts b/src/session-model.ts index f896f58d..590d3ee8 100644 --- a/src/session-model.ts +++ b/src/session-model.ts @@ -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>(); + /** 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> +>(); + +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; diff --git a/src/tests/acp-agent.test.ts b/src/tests/acp-agent.test.ts index 440483e3..172b8c21 100644 --- a/src/tests/acp-agent.test.ts +++ b/src/tests/acp-agent.test.ts @@ -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((resolve) => (sessionCreated = resolve)), - }; - const replaying = ( - agent as unknown as { - replaySessionHistory( - sessionId: string, - messages: unknown[], - pending: unknown, - ): Promise; - } - ).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 } + ).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).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({ diff --git a/src/tests/acp-scenarios/__snapshots__/air/session-load-replay.jsonl b/src/tests/acp-scenarios/__snapshots__/air/session-load-replay.jsonl index c54417cf..0b9e6e2d 100644 --- a/src/tests/acp-scenarios/__snapshots__/air/session-load-replay.jsonl +++ b/src/tests/acp-scenarios/__snapshots__/air/session-load-replay.jsonl @@ -8,9 +8,10 @@ {"kind":"sessionUpdate","payload":{"sessionId":"","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":"","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":"","update":{"rawOutput":"The file /src/app.ts has been updated successfully.","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_r_edit"}}} +{"kind":"sessionUpdate","payload":{"sessionId":":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":":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":"","update":{"content":{"text":"Fixed.","type":"text"},"messageId":"msg_r2","sessionUpdate":"agent_message_chunk"}}} -{"kind":"sessionUpdate","payload":{"sessionId":":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":"","update":{"sessionUpdate":"subagent_state_update","state":"completed","subagentSessionId":":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":""}} +{"kind":"sessionUpdate","payload":{"sessionId":":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":"","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/tests/create-session-options.test.ts b/src/tests/create-session-options.test.ts index 6058fa41..413934c8 100644 --- a/src/tests/create-session-options.test.ts +++ b/src/tests/create-session-options.test.ts @@ -2158,6 +2158,66 @@ describe("createSession options merging", () => { await loading; }); + it.each([ + ["without subagents", [] as Record[]], + [ + "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[] = []; diff --git a/src/tests/model-resolution.test.ts b/src/tests/model-resolution.test.ts index c73af979..3e203c23 100644 --- a/src/tests/model-resolution.test.ts +++ b/src/tests/model-resolution.test.ts @@ -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[] = [ diff --git a/src/tests/native-subagents.test.ts b/src/tests/native-subagents.test.ts index f1fc7b7f..f4fba6a3 100644 --- a/src/tests/native-subagents.test.ts +++ b/src/tests/native-subagents.test.ts @@ -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");