From b22d8b23943c658996c43ca5249d71650a42d0cf Mon Sep 17 00:00:00 2001 From: Nikita Ashikhmin Date: Sun, 25 Oct 2026 20:58:52 -0700 Subject: [PATCH 1/9] feat: report the model of each native subagent session After the adapter announces a native subagent session, it sends a config_option_update on the child session ID. The update holds one select option of category "model". The only entry is the current model of the child, because the client may not change it. The value and name of the entry are those of the same model in the model option of the parent session, so a client can compare them. A model that the parent option does not offer keeps its full ID. The model comes from the resolved model of the subAgentActivity item, then from the spawnAgent request. A spawn without a model gets the model of its parent. A child thread start or settings change that reports another model sends a new snapshot. A reopened child and a replayed child get the same update. This follows "Child configuration" in the subagents RFD. --- src/CodexAcpServer.ts | 55 ++++- src/ModelConfigOption.ts | 51 ++++- .../data/subagent-model-inherited.json | 35 +++ .../data/subagent-model-reopen.json | 76 +++++++ .../data/subagent-model-requested.json | 35 +++ .../CodexACPAgent/data/subagent-model-v2.json | 35 +++ .../CodexACPAgent/subagent-model.test.ts | 199 ++++++++++++++++++ .../scenarios/client-profiles.test.ts | 4 + .../scenarios/data/air/collab-controls.jsonl | 1 + .../data/air/late-subagent-update.jsonl | 1 + .../data/air/native-subagent-session.jsonl | 1 + .../data/air/nested-subagent-session.jsonl | 2 + .../data/air/subagent-activity.jsonl | 1 + src/subagents/CodexSubagentEventRouter.ts | 85 +++++++- 14 files changed, 566 insertions(+), 15 deletions(-) create mode 100644 src/__tests__/CodexACPAgent/data/subagent-model-inherited.json create mode 100644 src/__tests__/CodexACPAgent/data/subagent-model-reopen.json create mode 100644 src/__tests__/CodexACPAgent/data/subagent-model-requested.json create mode 100644 src/__tests__/CodexACPAgent/data/subagent-model-v2.json create mode 100644 src/__tests__/CodexACPAgent/subagent-model.test.ts diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index 27d739e10..7190b06c8 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -91,6 +91,7 @@ import { import type {ModeKind} from "./app-server/ModeKind"; import { createModelConfigOption, + createSubagentModelConfigOption, createReasoningEffortConfigOption, findSupportedEffort, formatModelDisplayName, @@ -170,7 +171,7 @@ import { clientSupportsSubagents, type SubagentAwareSessionCapabilities, } from "./subagents/AcpSubagents"; -import {CodexSubagentEventRouter} from "./subagents/CodexSubagentEventRouter"; +import {CodexSubagentEventRouter, type SubagentModels} from "./subagents/CodexSubagentEventRouter"; import {nameFromAgentPath} from "./subagents/CodexAgentPath"; import {listCodexHooks, trustCodexHooks, type CodexHookIdentity} from "./CodexHookTrust"; import type {HooksListEntry} from "./app-server/v2/HooksListEntry"; @@ -1213,6 +1214,7 @@ export class CodexAcpServer { clientSupportsSubagents(this.clientCapabilities), new ACPSessionConnection(this.connection, sessionId), childSessionId => this.reportingConnection.reports.releaseOpen(childSessionId), + () => this.subagentModels(sessionState), ), asyncTasks: this.createAsyncTasks(sessionId), compactions: new CodexSessionCompactions(), @@ -3122,6 +3124,19 @@ export class CodexAcpServer { await new ACPSessionConnection(this.connection, sessionState.sessionId).update(update); } + /** The models of a root session, for the configuration of its native subagent sessions. */ + private subagentModels(sessionState: SessionState): SubagentModels { + if (!this.isSessionConfigEnabled()) return {currentModel: null, availableModels: []}; + let currentModel: string | null = null; + try { + currentModel = ModelId.fromString(sessionState.currentModelId).model; + } + catch { + // A session without a known model has no model to give its subagents. + } + return {currentModel, availableModels: sessionState.availableModels}; + } + private findCurrentModel(models: Model[], currentModelId: string): Model | undefined { const modelId = ModelId.fromString(currentModelId); return models.find(m => m.id === modelId.model); @@ -3271,6 +3286,7 @@ export class CodexAcpServer { clientSupportsSubagents(this.clientCapabilities), new ACPSessionConnection(this.connection, sessionId), childSessionId => this.reportingConnection.reports.releaseOpen(childSessionId), + () => this.subagentModels(sessionState), ), asyncTasks: this.createAsyncTasks(sessionId), compactions: new CodexSessionCompactions(), @@ -3353,6 +3369,7 @@ export class CodexAcpServer { new Set([sessionId]), new Set(), isOpen, + this.subagentModels(sessionState).currentModel ?? undefined, ); return; } @@ -3402,9 +3419,20 @@ export class CodexAcpServer { ancestry: Set, unreadableChildren: Set, isOpen: () => boolean, + parentModel: string | undefined, ): Promise { const session = new ACPSessionConnection(this.connection, sessionId); - const announced = new Map(); + const announced = new Map(); + const requestedModels = new Map(); + // Sends the model of a replayed child after its announcement, as the live session does. + const reportModel = async (childSessionId: string, model: string | undefined) => { + if (!model) return; + const models = this.subagentModels(sessionState); + await session.update({ + sessionUpdate: "config_option_update", + configOptions: [createSubagentModelConfigOption(models.availableModels, models.currentModel, model)], + }, childSessionId); + }; // v2 only (this path also serves v1's native replay, unchanged there). const startedReplayMessages = new Set(); for await (const items of itemPages) { @@ -3436,6 +3464,10 @@ export class CodexAcpServer { // An interaction that started no child turn is not a new generation. if (activityKind === "interacted" && !childItems) continue; const name = nameFromAgentPath(item.agentPath, `Agent ${item.agentThreadId.slice(-8)}`); + const model = item.model + || previous?.model + || requestedModels.get(item.agentThreadId) + || parentModel; await session.update({ sessionUpdate: "subagent_spawned", subagentSessionId: childSessionId, @@ -3443,7 +3475,13 @@ export class CodexAcpServer { task: `Delegated task for ${name}`, capabilities: {}, }); - announced.set(item.agentThreadId, {generation, sessionId: childSessionId, terminal: false}); + announced.set(item.agentThreadId, { + generation, + sessionId: childSessionId, + terminal: false, + ...(model ? {model} : {}), + }); + await reportModel(childSessionId, model); if (childItems) { const commandIds = new Set(); try { @@ -3454,6 +3492,7 @@ export class CodexAcpServer { new Set([...ancestry, item.agentThreadId]), unreadableChildren, isOpen, + model, ); } catch (error) { @@ -3477,6 +3516,7 @@ export class CodexAcpServer { const child = announced.get(item.agentThreadId); if (!child) { const name = nameFromAgentPath(item.agentPath, `Agent ${item.agentThreadId.slice(-8)}`); + const model = item.model || requestedModels.get(item.agentThreadId) || parentModel; await session.update({ sessionUpdate: "subagent_spawned", subagentSessionId: item.agentThreadId, @@ -3488,7 +3528,9 @@ export class CodexAcpServer { generation: 1, sessionId: item.agentThreadId, terminal: false, + ...(model ? {model} : {}), }); + await reportModel(item.agentThreadId, model); continue; } if (child.terminal) continue; @@ -3502,7 +3544,12 @@ export class CodexAcpServer { continue; } // The activity items above replay the lifecycle of a spawn. A control call is a tool call, as in the live session. - if (item.type === "collabAgentToolCall" && item.tool === "spawnAgent") continue; + if (item.type === "collabAgentToolCall" && item.tool === "spawnAgent") { + if (item.model) { + for (const childThreadId of item.receiverThreadIds) requestedModels.set(childThreadId, item.model); + } + continue; + } for (const update of await this.createHistoryUpdates(item, sessionState)) { if (this.protocolVersion === 2) { await this.sendReplayMessageStart(session, update, startedReplayMessages); diff --git a/src/ModelConfigOption.ts b/src/ModelConfigOption.ts index f05efe0d5..21c2da652 100644 --- a/src/ModelConfigOption.ts +++ b/src/ModelConfigOption.ts @@ -33,23 +33,31 @@ export function findSupportedEffort( return options.find(o => o.reasoningEffort === effort)?.reasoningEffort; } -export function createModelConfigOption( - availableModels: Array, - currentBaseModelId: string, - recommendedModelId?: string, -): SessionConfigOption { - const options: Array<{ value: string; name: string; description: string | null }> = availableModels.map(model => ({ +type ModelOption = { value: string; name: string; description: string | null }; + +/** The entries of the session `model` option. */ +function modelOptions(availableModels: ReadonlyArray, currentBaseModelId: string | null): Array { + const options: Array = availableModels.map(model => ({ value: model.id, name: formatModelDisplayName(model.displayName), description: model.description, })); - if (!availableModels.some(model => model.id === currentBaseModelId)) { + if (currentBaseModelId !== null && !availableModels.some(model => model.id === currentBaseModelId)) { options.unshift({ value: currentBaseModelId, name: formatModelDisplayName(currentBaseModelId), description: null, }); } + return options; +} + +export function createModelConfigOption( + availableModels: Array, + currentBaseModelId: string, + recommendedModelId?: string, +): SessionConfigOption { + const options = modelOptions(availableModels, currentBaseModelId); const recommendation = recommendedModelId && options.some(option => option.value === recommendedModelId) ? recommendedModelId @@ -91,3 +99,32 @@ export function createReasoningEffortConfigOption( : {}), }; } + +/** + * The model option of a native subagent session. The client may not change the model of a subagent, so the + * option offers only the current model. Its value and name are those of the same model in the `model` option + * of the parent session, so that the client can compare them. A model that the parent option does not offer + * keeps its full id as the value and the name. + */ +export function createSubagentModelConfigOption( + availableModels: ReadonlyArray, + parentBaseModelId: string | null, + model: string, +): SessionConfigOption { + const parentOptions = modelOptions(availableModels, parentBaseModelId); + // Codex can name a model by its `model` slug instead of its id. + const id = availableModels.find(candidate => candidate.id === model)?.id + ?? availableModels.find(candidate => candidate.model === model)?.id + ?? model; + const option = parentOptions.find(candidate => candidate.value === id) + ?? {value: model, name: model, description: null}; + return { + id: MODEL_CONFIG_ID, + name: "Model", + description: "Model Codex uses for the subagent", + category: "model", + type: "select", + currentValue: option.value, + options: [option], + }; +} diff --git a/src/__tests__/CodexACPAgent/data/subagent-model-inherited.json b/src/__tests__/CodexACPAgent/data/subagent-model-inherited.json new file mode 100644 index 000000000..87bf96eb8 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/subagent-model-inherited.json @@ -0,0 +1,35 @@ +[ + { + "sessionId": "root", + "update": { + "sessionUpdate": "subagent_spawned", + "subagentSessionId": "child-1", + "name": "Reviewer", + "task": "Review the diff", + "capabilities": {} + } + }, + { + "sessionId": "child-1", + "update": { + "sessionUpdate": "config_option_update", + "configOptions": [ + { + "id": "model", + "name": "Model", + "description": "Model Codex uses for the subagent", + "category": "model", + "type": "select", + "currentValue": "gpt-5.5", + "options": [ + { + "value": "gpt-5.5", + "name": "5.5", + "description": "gpt-5.5 model" + } + ] + } + ] + } + } +] \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/subagent-model-reopen.json b/src/__tests__/CodexACPAgent/data/subagent-model-reopen.json new file mode 100644 index 000000000..1c55256fd --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/subagent-model-reopen.json @@ -0,0 +1,76 @@ +[ + { + "sessionId": "root", + "update": { + "sessionUpdate": "subagent_spawned", + "subagentSessionId": "child-1", + "name": "Reviewer", + "task": "Review the diff", + "capabilities": {} + } + }, + { + "sessionId": "child-1", + "update": { + "sessionUpdate": "config_option_update", + "configOptions": [ + { + "id": "model", + "name": "Model", + "description": "Model Codex uses for the subagent", + "category": "model", + "type": "select", + "currentValue": "gpt-5.2-codex", + "options": [ + { + "value": "gpt-5.2-codex", + "name": "5.2 Codex", + "description": "gpt-5.2-codex model" + } + ] + } + ] + } + }, + { + "sessionId": "root", + "update": { + "sessionUpdate": "subagent_state_update", + "subagentSessionId": "child-1", + "state": "completed" + } + }, + { + "sessionId": "root", + "update": { + "sessionUpdate": "subagent_spawned", + "subagentSessionId": "child-1:generation:2", + "name": "Reviewer", + "task": "Review the diff", + "capabilities": {} + } + }, + { + "sessionId": "child-1:generation:2", + "update": { + "sessionUpdate": "config_option_update", + "configOptions": [ + { + "id": "model", + "name": "Model", + "description": "Model Codex uses for the subagent", + "category": "model", + "type": "select", + "currentValue": "gpt-5.2-codex", + "options": [ + { + "value": "gpt-5.2-codex", + "name": "5.2 Codex", + "description": "gpt-5.2-codex model" + } + ] + } + ] + } + } +] \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/subagent-model-requested.json b/src/__tests__/CodexACPAgent/data/subagent-model-requested.json new file mode 100644 index 000000000..ad1a04410 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/subagent-model-requested.json @@ -0,0 +1,35 @@ +[ + { + "sessionId": "root", + "update": { + "sessionUpdate": "subagent_spawned", + "subagentSessionId": "child-1", + "name": "Reviewer", + "task": "Review the diff", + "capabilities": {} + } + }, + { + "sessionId": "child-1", + "update": { + "sessionUpdate": "config_option_update", + "configOptions": [ + { + "id": "model", + "name": "Model", + "description": "Model Codex uses for the subagent", + "category": "model", + "type": "select", + "currentValue": "gpt-5.2-codex", + "options": [ + { + "value": "gpt-5.2-codex", + "name": "5.2 Codex", + "description": "gpt-5.2-codex model" + } + ] + } + ] + } + } +] \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/subagent-model-v2.json b/src/__tests__/CodexACPAgent/data/subagent-model-v2.json new file mode 100644 index 000000000..242f10295 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/subagent-model-v2.json @@ -0,0 +1,35 @@ +[ + { + "sessionId": "root", + "update": { + "sessionUpdate": "_subagent_update", + "subagentSessionId": "child-1", + "name": "Reviewer", + "task": "Review the diff", + "capabilities": {} + } + }, + { + "sessionId": "child-1", + "update": { + "sessionUpdate": "config_option_update", + "configOptions": [ + { + "configId": "model", + "name": "Model", + "description": "Model Codex uses for the subagent", + "category": "model", + "type": "select", + "currentValue": "gpt-5.2-codex", + "options": [ + { + "value": "gpt-5.2-codex", + "name": "5.2 Codex", + "description": "gpt-5.2-codex model" + } + ] + } + ] + } + } +] \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/subagent-model.test.ts b/src/__tests__/CodexACPAgent/subagent-model.test.ts new file mode 100644 index 000000000..31b148f51 --- /dev/null +++ b/src/__tests__/CodexACPAgent/subagent-model.test.ts @@ -0,0 +1,199 @@ +import {afterEach, describe, expect, it, vi} from 'vitest'; +import type * as acpV2 from '@agentclientprotocol/sdk/experimental/v2/unstable'; +import {ACPSessionConnection, type AcpClientConnection, AcpV2Connection} from '../../ACPSessionConnection'; +import {CodexSubagentEventRouter, type SubagentModels} from '../../subagents/CodexSubagentEventRouter'; +import {createModelConfigOption} from '../../ModelConfigOption'; +import type {ServerNotification} from '../../app-server'; +import type {ThreadItem} from '../../app-server/v2'; +import {createTestModel} from '../acp-test-utils'; +import {checkV2SessionUpdate, expectConformingV2SessionUpdates} from './v2-session-update-guard'; + +type SentUpdate = {sessionId: string; update: Record}; + +const MODELS: SubagentModels = { + currentModel: "gpt-5.5", + availableModels: [ + createTestModel({id: "gpt-5.5", displayName: "gpt-5.5"}), + createTestModel({id: "gpt-5.2-codex", model: "gpt-5.2-codex", displayName: "gpt-5.2-codex", isDefault: false}), + ], +}; + +function v1Router(models: SubagentModels = MODELS): {router: CodexSubagentEventRouter; sent: SentUpdate[]} { + const sent: SentUpdate[] = []; + const connection = { + notify: vi.fn(async (_method: string, params: unknown) => { + sent.push(params as SentUpdate); + }), + request: vi.fn(), + } as unknown as AcpClientConnection; + const session = new ACPSessionConnection(connection, "root"); + return {router: new CodexSubagentEventRouter("root", true, session, () => {}, () => models), sent}; +} + +function v2Router(): {router: CodexSubagentEventRouter; sent: SentUpdate[]} { + const sent: SentUpdate[] = []; + const v2Client = { + notify: vi.fn(async (_method: string, params: unknown) => { + const notification = params as {sessionId: string; update: acpV2.SessionUpdate}; + checkV2SessionUpdate(notification.update); + sent.push(notification as unknown as SentUpdate); + }), + request: vi.fn(), + }; + const view = new AcpV2Connection(v2Client as any).extensionOnlyV1View(); + const session = new ACPSessionConnection(view, "root"); + return {router: new CodexSubagentEventRouter("root", true, session, () => {}, () => MODELS), sent}; +} + +function spawn(model: string | null): ServerNotification { + const item: ThreadItem = { + type: "collabAgentToolCall", + id: "spawn-1", + tool: "spawnAgent", + status: "inProgress", + senderThreadId: "root", + receiverThreadIds: ["child-1"], + prompt: "Review the diff", + model, + reasoningEffort: null, + agentsStates: {"child-1": {status: "running", message: null}}, + }; + return {method: "item/started", params: {threadId: "root", turnId: "turn-1", startedAtMs: 0, item}}; +} + +function activity(model: string | null): ServerNotification { + const item: ThreadItem = { + type: "subAgentActivity", + model, + reasoningEffort: null, + id: "activity-started", + kind: "started", + agentThreadId: "child-1", + agentPath: "/root/reviewer", + }; + return {method: "item/started", params: {threadId: "root", turnId: "turn-1", startedAtMs: 0, item}}; +} + +function childTurn(id: string, status: "completed" | "inProgress"): ServerNotification { + const turn = { + id, + items: [], + rootTurnId: null, + itemsView: "notLoaded" as const, + status, + error: null, + startedAt: null, + completedAt: null, + durationMs: null, + }; + return status === "completed" + ? {method: "turn/completed", params: {threadId: "child-1", turn}} + : {method: "turn/started", params: {threadId: "child-1", turn}}; +} + +function modelOptionOf(update: Record): Record { + return (update["configOptions"] as Array>)[0]!; +} + +describe('the model of a native subagent session', () => { + afterEach(() => { + expectConformingV2SessionUpdates(); + }); + + it('reports the requested model on the child session right after the announcement', async () => { + const {router, sent} = v1Router(); + + await router.handle(spawn("gpt-5.2-codex")); + await router.handle(activity(null)); + + expect(sent.map(({sessionId, update}) => `${sessionId} ${update["sessionUpdate"]}`)).toEqual([ + "root subagent_spawned", + "child-1 config_option_update", + ]); + await expect(JSON.stringify(sent, null, 2)).toMatchFileSnapshot('data/subagent-model-requested.json'); + }); + + it('uses the value and the name of the same model in the parent model option', async () => { + const {router, sent} = v1Router(); + + await router.handle(spawn("gpt-5.2-codex")); + await router.handle(activity(null)); + + const parentOption = createModelConfigOption([...MODELS.availableModels], MODELS.currentModel!); + const parentEntry = ((parentOption as {options: Array<{value: string}>}).options) + .find(option => option.value === "gpt-5.2-codex"); + const childOption = modelOptionOf(sent[1]!.update); + expect(childOption["currentValue"]).toBe("gpt-5.2-codex"); + expect(childOption["options"]).toEqual([parentEntry]); + }); + + it('reports the model of the parent for a spawn that requests no model', async () => { + const {router, sent} = v1Router(); + + await router.handle(spawn(null)); + await router.handle(activity(null)); + + await expect(JSON.stringify(sent, null, 2)).toMatchFileSnapshot('data/subagent-model-inherited.json'); + }); + + it('prefers the model that Codex resolved for the child, and keeps an unknown model id as it is', async () => { + const {router, sent} = v1Router(); + + await router.handle(spawn("gpt-5.2-codex")); + await router.handle(activity("custom-model")); + + expect(modelOptionOf(sent[1]!.update)).toMatchObject({ + currentValue: "custom-model", + options: [{value: "custom-model", name: "custom-model", description: null}], + }); + }); + + it('reports the model again on the new session of a reopened child', async () => { + const {router, sent} = v1Router(); + + await router.handle(spawn("gpt-5.2-codex")); + await router.handle(activity(null)); + await router.handle(childTurn("first-turn", "completed")); + await router.handle(childTurn("followup-turn", "inProgress")); + + await expect(JSON.stringify(sent, null, 2)).toMatchFileSnapshot('data/subagent-model-reopen.json'); + }); + + it('reports a new snapshot when the settings of the child thread change its model', async () => { + const {router, sent} = v1Router(); + + await router.handle(spawn(null)); + await router.handle(activity(null)); + const settings = (model: string): ServerNotification => ({ + method: "thread/settings/updated", + params: {threadId: "child-1", threadSettings: {model} as never}, + }); + await router.handle(settings("gpt-5.5")); + await router.handle(settings("gpt-5.2-codex")); + + expect(sent.map(({sessionId, update}) => [sessionId, update["sessionUpdate"]])).toEqual([ + ["root", "subagent_spawned"], + ["child-1", "config_option_update"], + ["child-1", "config_option_update"], + ]); + expect(modelOptionOf(sent[2]!.update)["currentValue"]).toBe("gpt-5.2-codex"); + }); + + it('sends no configuration when the model of the child is not known', async () => { + const {router, sent} = v1Router({currentModel: null, availableModels: []}); + + await router.handle(spawn(null)); + await router.handle(activity(null)); + + expect(sent.map(({update}) => update["sessionUpdate"])).toEqual(["subagent_spawned"]); + }); + + it('renders the child configuration in the ACP v2 shape', async () => { + const {router, sent} = v2Router(); + + await router.handle(spawn("gpt-5.2-codex")); + await router.handle(activity(null)); + + await expect(JSON.stringify(sent, null, 2)).toMatchFileSnapshot('data/subagent-model-v2.json'); + }); +}); diff --git a/src/__tests__/scenarios/client-profiles.test.ts b/src/__tests__/scenarios/client-profiles.test.ts index cc23f94f5..7ca29c35b 100644 --- a/src/__tests__/scenarios/client-profiles.test.ts +++ b/src/__tests__/scenarios/client-profiles.test.ts @@ -421,6 +421,7 @@ describe("subagents", () => { it("air: gets the child session before the child output, and the child state on the parent", () => { expect(timeline("air", "native-subagent-session")).toEqual([ "session-1 subagent_spawned child-thread", + "child-thread config_option_update", "child-thread tool_call child-cmd", "child-thread tool_call_update child-cmd", "child-thread tool_call_update child-cmd", @@ -434,7 +435,9 @@ describe("subagents", () => { it("air: gets a nested child on its immediate parent", () => { expect(timeline("air", "nested-subagent-session")).toEqual([ "session-1 subagent_spawned child-thread", + "child-thread config_option_update", "child-thread subagent_spawned grandchild-thread", + "grandchild-thread config_option_update", "grandchild-thread tool_call grandchild-cmd", "grandchild-thread tool_call_update grandchild-cmd", "grandchild-thread agent_message_chunk", @@ -446,6 +449,7 @@ describe("subagents", () => { it("air: gets no child update after the child ends", () => { expect(timeline("air", "late-subagent-update")).toEqual([ "session-1 subagent_spawned child-thread", + "child-thread config_option_update", "child-thread tool_call child-cmd", "session-1 subagent_state_update child-thread", ]); diff --git a/src/__tests__/scenarios/data/air/collab-controls.jsonl b/src/__tests__/scenarios/data/air/collab-controls.jsonl index bb098c469..72b8e84df 100644 --- a/src/__tests__/scenarios/data/air/collab-controls.jsonl +++ b/src/__tests__/scenarios/data/air/collab-controls.jsonl @@ -3,6 +3,7 @@ {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"capabilities":{},"name":"Weather","sessionUpdate":"subagent_spawned","subagentSessionId":"child-thread","task":"Find the weather in Paris."}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"child-thread","update":{"configOptions":[{"category":"model","currentValue":"model-id","description":"Model Codex uses for the subagent","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"}],"sessionUpdate":"config_option_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"kind":"other","rawInput":{"agentsStates":{"child-thread":{"message":null,"status":"running"}},"model":null,"prompt":null,"reasoningEffort":null,"receiverThreadIds":["child-thread"],"senderThreadId":"session-1"},"sessionUpdate":"tool_call","status":"in_progress","title":"wait","toolCallId":"wait-1"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"wait-1"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"kind":"other","rawInput":{"agentsStates":{"child-thread":{"message":null,"status":"running"}},"model":null,"prompt":"Use Celsius.","reasoningEffort":null,"receiverThreadIds":["child-thread"],"senderThreadId":"session-1"},"sessionUpdate":"tool_call","status":"in_progress","title":"sendInput","toolCallId":"send-1"}}} diff --git a/src/__tests__/scenarios/data/air/late-subagent-update.jsonl b/src/__tests__/scenarios/data/air/late-subagent-update.jsonl index 4f5f2c047..06f3dae33 100644 --- a/src/__tests__/scenarios/data/air/late-subagent-update.jsonl +++ b/src/__tests__/scenarios/data/air/late-subagent-update.jsonl @@ -3,6 +3,7 @@ {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"capabilities":{},"name":"Weather","sessionUpdate":"subagent_spawned","subagentSessionId":"child-thread","task":"Find the weather in Paris."}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"child-thread","update":{"configOptions":[{"category":"model","currentValue":"model-id","description":"Model Codex uses for the subagent","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"}],"sessionUpdate":"config_option_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"child-thread","update":{"_meta":{"terminal_info":{"cwd":"/workspace","terminal_id":"child-cmd"}},"content":[{"terminalId":"child-cmd","type":"terminal"}],"kind":"execute","rawInput":{"command":"curl wttr.in","cwd":"/workspace"},"sessionUpdate":"tool_call","status":"in_progress","title":"curl wttr.in","toolCallId":"child-cmd"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"subagent_state_update","state":"completed","subagentSessionId":"child-thread"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"session_info_update","title":"Go"}}} diff --git a/src/__tests__/scenarios/data/air/native-subagent-session.jsonl b/src/__tests__/scenarios/data/air/native-subagent-session.jsonl index 6a131504c..2d2133b2f 100644 --- a/src/__tests__/scenarios/data/air/native-subagent-session.jsonl +++ b/src/__tests__/scenarios/data/air/native-subagent-session.jsonl @@ -3,6 +3,7 @@ {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"capabilities":{},"name":"Weather","sessionUpdate":"subagent_spawned","subagentSessionId":"child-thread","task":"Find the weather in Paris."}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"child-thread","update":{"configOptions":[{"category":"model","currentValue":"model-id","description":"Model Codex uses for the subagent","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"}],"sessionUpdate":"config_option_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"child-thread","update":{"_meta":{"terminal_info":{"cwd":"/workspace","terminal_id":"child-cmd"}},"content":[{"terminalId":"child-cmd","type":"terminal"}],"kind":"execute","rawInput":{"command":"curl wttr.in","cwd":"/workspace"},"sessionUpdate":"tool_call","status":"in_progress","title":"curl wttr.in","toolCallId":"child-cmd"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"child-thread","update":{"_meta":{"terminal_output_delta":{"data":"Sunny\n","terminal_id":"child-cmd"}},"sessionUpdate":"tool_call_update","toolCallId":"child-cmd"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"child-thread","update":{"_meta":{"terminal_exit":{"exit_code":0,"signal":null,"terminal_id":"child-cmd"}},"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"child-cmd"}}} diff --git a/src/__tests__/scenarios/data/air/nested-subagent-session.jsonl b/src/__tests__/scenarios/data/air/nested-subagent-session.jsonl index dbef295c5..58a8f77a8 100644 --- a/src/__tests__/scenarios/data/air/nested-subagent-session.jsonl +++ b/src/__tests__/scenarios/data/air/nested-subagent-session.jsonl @@ -3,7 +3,9 @@ {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"capabilities":{},"name":"Weather","sessionUpdate":"subagent_spawned","subagentSessionId":"child-thread","task":"Find the weather in Paris."}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"child-thread","update":{"configOptions":[{"category":"model","currentValue":"model-id","description":"Model Codex uses for the subagent","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"}],"sessionUpdate":"config_option_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"child-thread","update":{"capabilities":{},"name":"Lyon","sessionUpdate":"subagent_spawned","subagentSessionId":"grandchild-thread","task":"Check Lyon."}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"grandchild-thread","update":{"configOptions":[{"category":"model","currentValue":"model-id","description":"Model Codex uses for the subagent","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"}],"sessionUpdate":"config_option_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"grandchild-thread","update":{"_meta":{"terminal_info":{"cwd":"/workspace","terminal_id":"grandchild-cmd"}},"content":[{"terminalId":"grandchild-cmd","type":"terminal"}],"kind":"execute","rawInput":{"command":"curl wttr.in/Lyon","cwd":"/workspace"},"sessionUpdate":"tool_call","status":"in_progress","title":"curl wttr.in/Lyon","toolCallId":"grandchild-cmd"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"grandchild-thread","update":{"_meta":{"terminal_exit":{"exit_code":0,"signal":null,"terminal_id":"grandchild-cmd"},"terminal_output_delta":{"data":"Rain\n","terminal_id":"grandchild-cmd"}},"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"grandchild-cmd"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"grandchild-thread","update":{"content":{"text":"Rain in Lyon","type":"text"},"messageId":"grandchild-msg","sessionUpdate":"agent_message_chunk"}}} diff --git a/src/__tests__/scenarios/data/air/subagent-activity.jsonl b/src/__tests__/scenarios/data/air/subagent-activity.jsonl index b3b0baab8..ec9bc3d38 100644 --- a/src/__tests__/scenarios/data/air/subagent-activity.jsonl +++ b/src/__tests__/scenarios/data/air/subagent-activity.jsonl @@ -3,6 +3,7 @@ {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"capabilities":{},"name":"Weather","sessionUpdate":"subagent_spawned","subagentSessionId":"child-thread","task":"Delegated task for Weather"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"child-thread","update":{"configOptions":[{"category":"model","currentValue":"model-id","description":"Model Codex uses for the subagent","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"}],"sessionUpdate":"config_option_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"child-thread","update":{"content":{"text":"Sunny","type":"text"},"messageId":"child-msg","sessionUpdate":"agent_message_chunk"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"subagent_state_update","state":"completed","subagentSessionId":"child-thread"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"session_info_update","title":"Go"}}} diff --git a/src/subagents/CodexSubagentEventRouter.ts b/src/subagents/CodexSubagentEventRouter.ts index 5bb3ebfb7..ac598c1f1 100644 --- a/src/subagents/CodexSubagentEventRouter.ts +++ b/src/subagents/CodexSubagentEventRouter.ts @@ -1,7 +1,8 @@ import type {ServerNotification} from "../app-server"; -import type {ThreadItem} from "../app-server/v2"; +import type {Model, ThreadItem} from "../app-server/v2"; import type {ACPSessionConnection} from "../ACPSessionConnection"; import {logger} from "../Logger"; +import {createSubagentModelConfigOption} from "../ModelConfigOption"; import {CollabAgentReporter} from "../tool-calls/reporters/CollabAgentReporter"; import {SubagentActivityReporter} from "../tool-calls/reporters/SubagentActivityReporter"; import type {ToolFacts} from "../tool-calls/ToolFacts"; @@ -16,6 +17,8 @@ type NativeSubagent = { name: string; task: string; path?: string; + /** The model of the child, or `undefined` when the adapter does not know it. */ + model?: string; generation: number; terminalState?: SubagentState; }; @@ -24,6 +27,8 @@ type PendingSubagent = { parentThreadId: string; parentSessionId: string; task: string; + /** The model that the spawn requested, or the model of the child thread when Codex reported it. */ + model?: string; buffered: PendingNotificationBuffer; }; @@ -31,8 +36,18 @@ type PendingSubagent = { type EndedSpawn = { parentThreadId: string; task: string; + model?: string; }; +/** The models of the root session, which a child that does not request a model inherits. */ +export type SubagentModels = { + /** The base model id of the root session, or `null` when it is not known. */ + currentModel: string | null; + availableModels: ReadonlyArray; +}; + +const NO_MODELS: SubagentModels = {currentModel: null, availableModels: []}; + export type ClosingChildSession = { threadId: string; sessionId: string; @@ -54,15 +69,22 @@ export class CodexSubagentEventRouter { /** * @param onChildSessionEnded runs when a native child session ends, so that the owner can release * the state that it keeps per child session. + * @param rootModels gives the models of the root session when a child needs them. */ constructor( private readonly rootSessionId: string, private readonly supported: boolean, private readonly session: ACPSessionConnection, private readonly onChildSessionEnded: (sessionId: string) => void, + private readonly rootModels: () => SubagentModels = () => NO_MODELS, ) {} async handle(notification: ServerNotification): Promise { + if (notification.method === "thread/started") { + const thread = notification.params.thread; + if (thread.model) await this.applyThreadModel(thread.id, thread.model); + return false; + } if (notification.method === "turn/started") { const childTurn = this.isKnownChild(notification.params.threadId); // A follow-up can start a new turn of an ended child with no collaboration item that reports it. @@ -91,6 +113,10 @@ export class CodexSubagentEventRouter { this.pendingSpawns.get(notificationThreadId)!.buffered.push(notification); return true; } + if (notification.method === "thread/settings/updated") { + await this.applyThreadModel(notification.params.threadId, notification.params.threadSettings.model); + return false; + } if (notification.method !== "item/started" && notification.method !== "item/completed") { return false; } @@ -108,7 +134,7 @@ export class CodexSubagentEventRouter { if (this.terminalPendingSpawns.has(item.agentThreadId)) return true; let hasNativeRepresentation = this.children.has(item.agentThreadId); if (!hasNativeRepresentation) { - await this.materialize(item.agentThreadId, item.agentPath); + await this.materialize(item.agentThreadId, item.agentPath, item.model); hasNativeRepresentation = this.children.has(item.agentThreadId); } if (hasNativeRepresentation && item.kind === "interrupted") { @@ -151,6 +177,7 @@ export class CodexSubagentEventRouter { parentThreadId, parentSessionId, task: item.prompt?.trim() || "Delegated task", + ...(item.model ? {model: item.model} : {}), buffered: new PendingNotificationBuffer(childSessionId), }); representedSpawn = true; @@ -324,7 +351,7 @@ export class CodexSubagentEventRouter { : []; } - private async materialize(childSessionId: string, path: string): Promise { + private async materialize(childSessionId: string, path: string, resolvedModel: string | null): Promise { if (this.children.has(childSessionId)) return; const pending = this.pendingSpawns.get(childSessionId); const name = nameFromAgentPath(path, fallbackName(childSessionId)); @@ -332,6 +359,7 @@ export class CodexSubagentEventRouter { const parentThreadId = pending?.parentThreadId ?? inferredParent.threadId; const parentSessionId = pending?.parentSessionId ?? inferredParent.sessionId; const task = pending?.task ?? `Delegated task for ${name}`; + const model = resolvedModel || pending?.model || this.inheritedModel(parentThreadId); await this.session.update({ sessionUpdate: "subagent_spawned", subagentSessionId: childSessionId, @@ -346,9 +374,11 @@ export class CodexSubagentEventRouter { name, task, path: normalizeAgentPath(path), + ...(model ? {model} : {}), generation: 1, }); this.pendingSpawns.delete(childSessionId); + await this.reportModel(childSessionId, model); // A loop, not push(...): a spread of a large buffer exceeds the argument limit and throws. for (const notification of pending?.buffered.take() ?? []) this.replayQueue.push(notification); this.resolveMaterialization(childSessionId, childSessionId); @@ -360,7 +390,11 @@ export class CodexSubagentEventRouter { this.pendingSpawns.delete(childSessionId); // The child ended before Codex reported its activity, so it is not shown. The record keeps no buffer, // so its buffered updates are dropped. - this.terminalPendingSpawns.set(childSessionId, {parentThreadId: pending.parentThreadId, task: pending.task}); + this.terminalPendingSpawns.set(childSessionId, { + parentThreadId: pending.parentThreadId, + task: pending.task, + ...(pending.model ? {model: pending.model} : {}), + }); this.onChildSessionEnded(childSessionId); this.resolveMaterialization(childSessionId, null); this.notifyWaiters(); @@ -391,12 +425,14 @@ export class CodexSubagentEventRouter { const pending = this.terminalPendingSpawns.get(childThreadId); if (!pending) return; const parentSessionId = this.children.get(pending.parentThreadId)?.sessionId ?? this.rootSessionId; + const model = pending.model ?? this.inheritedModel(pending.parentThreadId); const reopened: NativeSubagent = { parentThreadId: pending.parentThreadId, parentSessionId, sessionId: `${childThreadId}:generation:2`, name: fallbackName(childThreadId), task: pending.task, + ...(model ? {model} : {}), generation: 2, }; await this.session.update({ @@ -408,6 +444,7 @@ export class CodexSubagentEventRouter { }, parentSessionId); this.children.set(childThreadId, reopened); this.terminalPendingSpawns.delete(childThreadId); + await this.reportModel(reopened.sessionId, reopened.model); return; } if (!child.terminalState) return; @@ -434,6 +471,46 @@ export class CodexSubagentEventRouter { child.terminalState = previousState; throw error; } + await this.reportModel(child.sessionId, child.model); + } + + /** Takes the model that Codex reports for a child thread. It replaces a requested or inherited model. */ + private async applyThreadModel(threadId: string, model: string): Promise { + if (threadId === this.rootSessionId) return; + const pending = this.pendingSpawns.get(threadId); + if (pending) { + pending.model = model; + return; + } + const ended = this.terminalPendingSpawns.get(threadId); + if (ended) { + ended.model = model; + return; + } + const child = this.children.get(threadId); + if (!child || child.model === model) return; + child.model = model; + if (child.terminalState === undefined) await this.reportModel(child.sessionId, model); + } + + /** A child that requests no model runs with the model of its parent. */ + private inheritedModel(parentThreadId: string): string | undefined { + const parent = this.children.get(parentThreadId); + if (parent) return parent.model; + return this.rootModels().currentModel ?? undefined; + } + + /** + * Sends the configuration of a child session after its announcement. The client may not change it, so the + * model option offers only the current model. A child whose model is not known gets no configuration. + */ + private async reportModel(childSessionId: string, model: string | undefined): Promise { + if (!model) return; + const models = this.rootModels(); + await this.session.update({ + sessionUpdate: "config_option_update", + configOptions: [createSubagentModelConfigOption(models.availableModels, models.currentModel, model)], + }, childSessionId); } private resolveMaterialization(childThreadId: string, sessionId: string | null): void { From 0736ae396f0ff2f09a570b06230c774ae4461767 Mon Sep 17 00:00:00 2001 From: Nikita Ashikhmin Date: Sun, 25 Oct 2026 21:00:02 -0700 Subject: [PATCH 2/9] refactor: pass the root session models to every subagent router explicitly --- .../CodexACPAgent/agent-file-change-report.test.ts | 1 + src/__tests__/CodexACPAgent/async-tasks.test.ts | 2 +- src/__tests__/CodexACPAgent/collab-agent-events.test.ts | 8 ++++++++ src/__tests__/CodexACPAgent/session-compaction.test.ts | 1 + src/__tests__/CodexACPAgent/session-notices.test.ts | 1 + .../subagent-and-async-task-updates-v2.test.ts | 6 +++--- src/__tests__/CodexACPAgent/token-usage-events.test.ts | 1 + src/__tests__/acp-test-utils.ts | 1 + src/subagents/CodexSubagentEventRouter.ts | 4 +--- 9 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/__tests__/CodexACPAgent/agent-file-change-report.test.ts b/src/__tests__/CodexACPAgent/agent-file-change-report.test.ts index ed81fe369..55477ce7a 100644 --- a/src/__tests__/CodexACPAgent/agent-file-change-report.test.ts +++ b/src/__tests__/CodexACPAgent/agent-file-change-report.test.ts @@ -167,6 +167,7 @@ async function setupNativeSubagentPrompt(): Promise<{ true, new ACPSessionConnection(fixture.getAcpConnection(), sessionState.sessionId), () => {}, + () => ({currentModel: null, availableModels: []}), ); vi.spyOn(fixture.getCodexAcpAgent(), "getSessionState").mockReturnValue(sessionState); vi.spyOn(fixture.getCodexAppServerClient(), "turnStart") diff --git a/src/__tests__/CodexACPAgent/async-tasks.test.ts b/src/__tests__/CodexACPAgent/async-tasks.test.ts index 23a02501a..915fbe97c 100644 --- a/src/__tests__/CodexACPAgent/async-tasks.test.ts +++ b/src/__tests__/CodexACPAgent/async-tasks.test.ts @@ -490,7 +490,7 @@ describe("Codex background terminal tasks", () => { }); const sessionState = createTestSessionState({sessionId: "thread-1"}); const session = new ACPSessionConnection(fixture.getAcpConnection(), sessionState.sessionId); - sessionState.subagents = new CodexSubagentEventRouter(sessionState.sessionId, true, session, () => {}); + sessionState.subagents = new CodexSubagentEventRouter(sessionState.sessionId, true, session, () => {}, () => ({currentModel: null, availableModels: []})); sessionState.asyncTasks = new CodexBackgroundTerminalTasks( true, sessionState.sessionId, diff --git a/src/__tests__/CodexACPAgent/collab-agent-events.test.ts b/src/__tests__/CodexACPAgent/collab-agent-events.test.ts index fac54d14d..ed552c590 100644 --- a/src/__tests__/CodexACPAgent/collab-agent-events.test.ts +++ b/src/__tests__/CodexACPAgent/collab-agent-events.test.ts @@ -44,6 +44,7 @@ describe("CodexEventHandler - collab agent tool call events", () => { true, new ACPSessionConnection(mockFixture.getAcpConnection(), sessionId), () => {}, + () => ({currentModel: null, availableModels: []}), ); return response; } @@ -1139,6 +1140,7 @@ describe("CodexEventHandler - collab agent tool call events", () => { true, new ACPSessionConnection(mockFixture.getAcpConnection(), sessionId), () => {}, + () => ({currentModel: null, availableModels: []}), ); await router.handle({ method: "item/started", @@ -1614,6 +1616,7 @@ describe("CodexEventHandler - collab agent tool call events", () => { true, new ACPSessionConnection(mockFixture.getAcpConnection(), sessionId), () => {}, + () => ({currentModel: null, availableModels: []}), ); const activity = (threadId: string, path: string): ServerNotification => ({ method: "item/started", @@ -1690,6 +1693,7 @@ describe("CodexEventHandler - collab agent tool call events", () => { true, new ACPSessionConnection(mockFixture.getAcpConnection(), sessionId), () => {}, + () => ({currentModel: null, availableModels: []}), ); await router.handle({ method: "item/started", @@ -1751,6 +1755,7 @@ describe("CodexEventHandler - collab agent tool call events", () => { true, new ACPSessionConnection(mockFixture.getAcpConnection(), sessionId), () => {}, + () => ({currentModel: null, availableModels: []}), ); const spawn = (item: Record) => router.handle({ method: "item/started", @@ -1781,6 +1786,7 @@ describe("CodexEventHandler - collab agent tool call events", () => { true, new ACPSessionConnection(mockFixture.getAcpConnection(), sessionId), childSessionId => ended.push(childSessionId), + () => ({currentModel: null, availableModels: []}), ); const rootItem = (item: Record) => router.handle({ method: "item/started", @@ -1836,6 +1842,7 @@ describe("CodexEventHandler - collab agent tool call events", () => { true, new ACPSessionConnection(mockFixture.getAcpConnection(), sessionId), () => {}, + () => ({currentModel: null, availableModels: []}), ); await router.handle({ method: "item/started", @@ -1942,6 +1949,7 @@ describe("CodexEventHandler - collab agent tool call events", () => { true, new ACPSessionConnection(mockFixture.getAcpConnection(), sessionId), childSessionId => reports.releaseOpen(childSessionId), + () => ({currentModel: null, availableModels: []}), ); await router.handle(spawn(childThreadId)); if (materialize) await router.handle(activity(childThreadId)); diff --git a/src/__tests__/CodexACPAgent/session-compaction.test.ts b/src/__tests__/CodexACPAgent/session-compaction.test.ts index 6dc6d3092..0064540e7 100644 --- a/src/__tests__/CodexACPAgent/session-compaction.test.ts +++ b/src/__tests__/CodexACPAgent/session-compaction.test.ts @@ -443,6 +443,7 @@ async function createFixture(clientCapabilities: acp.ClientCapabilities = compac true, new ACPSessionConnection(fixture.getAcpConnection(), sessionId), () => {}, + () => ({currentModel: null, availableModels: []}), ); } vi.spyOn(agent, "getSessionState").mockReturnValue(sessionState); diff --git a/src/__tests__/CodexACPAgent/session-notices.test.ts b/src/__tests__/CodexACPAgent/session-notices.test.ts index 2b353bd16..3981af17b 100644 --- a/src/__tests__/CodexACPAgent/session-notices.test.ts +++ b/src/__tests__/CodexACPAgent/session-notices.test.ts @@ -216,6 +216,7 @@ async function createFixture(clientCapabilities?: unknown, nativeSubagents = fal true, new ACPSessionConnection(fixture.getAcpConnection(), sessionId), () => {}, + () => ({currentModel: null, availableModels: []}), ); } vi.spyOn(agent, "getSessionState").mockReturnValue(sessionState); diff --git a/src/__tests__/CodexACPAgent/subagent-and-async-task-updates-v2.test.ts b/src/__tests__/CodexACPAgent/subagent-and-async-task-updates-v2.test.ts index fcd1c6504..53456e906 100644 --- a/src/__tests__/CodexACPAgent/subagent-and-async-task-updates-v2.test.ts +++ b/src/__tests__/CodexACPAgent/subagent-and-async-task-updates-v2.test.ts @@ -29,7 +29,7 @@ describe('subagent and async task updates on ACP v2', () => { it('renders the subagent lifecycle as `_subagent_update` when native subagents are enabled', async () => { const {session, updates} = v2Session("root"); - const router = new CodexSubagentEventRouter("root", true, session, () => {}); + const router = new CodexSubagentEventRouter("root", true, session, () => {}, () => ({currentModel: null, availableModels: []})); await router.handle({ method: "item/started", @@ -84,7 +84,7 @@ describe('subagent and async task updates on ACP v2', () => { it('renders a follow-up turn of a finished subagent as a new `_subagent_update` generation', async () => { const {session, updates} = v2Session("root"); - const router = new CodexSubagentEventRouter("root", true, session, () => {}); + const router = new CodexSubagentEventRouter("root", true, session, () => {}, () => ({currentModel: null, availableModels: []})); const childTurn = (id: string, status: "completed" | "inProgress") => router.handle({ method: status === "completed" ? "turn/completed" : "turn/started", params: { @@ -146,7 +146,7 @@ describe('subagent and async task updates on ACP v2', () => { it('never emits `_subagent_update` (or `subagent_spawned`) when native subagents are disabled', async () => { const {session, updates} = v2Session("root"); - const router = new CodexSubagentEventRouter("root", false, session, () => {}); + const router = new CodexSubagentEventRouter("root", false, session, () => {}, () => ({currentModel: null, availableModels: []})); const handled = await router.handle({ method: "item/started", diff --git a/src/__tests__/CodexACPAgent/token-usage-events.test.ts b/src/__tests__/CodexACPAgent/token-usage-events.test.ts index 1b0a50649..1683526bd 100644 --- a/src/__tests__/CodexACPAgent/token-usage-events.test.ts +++ b/src/__tests__/CodexACPAgent/token-usage-events.test.ts @@ -702,6 +702,7 @@ describe('Token Usage Events', () => { true, new ACPSessionConnection(mockFixture.getAcpConnection(), sessionId), () => {}, + () => ({currentModel: null, availableModels: []}), ); mockFixture.getCodexAppServerClient().turnStart = vi.fn().mockResolvedValue({ turn: { id: 'turn-id', items: [], status: 'inProgress', error: null }, diff --git a/src/__tests__/acp-test-utils.ts b/src/__tests__/acp-test-utils.ts index 4608b591e..fba992e71 100644 --- a/src/__tests__/acp-test-utils.ts +++ b/src/__tests__/acp-test-utils.ts @@ -456,6 +456,7 @@ export function createTestSessionState(overrides?: Partial): Sessi false, new ACPSessionConnection({notify: vi.fn(), request: vi.fn()} as AcpClientConnection, sessionId), () => {}, + () => ({currentModel: null, availableModels: []}), ), asyncTasks: new CodexBackgroundTerminalTasks( false, diff --git a/src/subagents/CodexSubagentEventRouter.ts b/src/subagents/CodexSubagentEventRouter.ts index ac598c1f1..ec43d247d 100644 --- a/src/subagents/CodexSubagentEventRouter.ts +++ b/src/subagents/CodexSubagentEventRouter.ts @@ -46,8 +46,6 @@ export type SubagentModels = { availableModels: ReadonlyArray; }; -const NO_MODELS: SubagentModels = {currentModel: null, availableModels: []}; - export type ClosingChildSession = { threadId: string; sessionId: string; @@ -76,7 +74,7 @@ export class CodexSubagentEventRouter { private readonly supported: boolean, private readonly session: ACPSessionConnection, private readonly onChildSessionEnded: (sessionId: string) => void, - private readonly rootModels: () => SubagentModels = () => NO_MODELS, + private readonly rootModels: () => SubagentModels, ) {} async handle(notification: ServerNotification): Promise { From 1eb850a5783abf607a68690968f52279b00af960 Mon Sep 17 00:00:00 2001 From: Nikita Ashikhmin Date: Sun, 25 Oct 2026 21:02:33 -0700 Subject: [PATCH 3/9] fix: replay the model of a subagent only when the history names it History replay now sends the model option of a child only when the history names its model: the resolved model of the subAgentActivity item, or the model of the spawnAgent request. The current model of the parent can differ from the model at the spawn, so replay does not use it. A child without a named model gets no model option. --- src/CodexAcpServer.ts | 14 +- .../CodexACPAgent/load-session.test.ts | 121 ++++++++++++++++++ 2 files changed, 126 insertions(+), 9 deletions(-) diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index 7190b06c8..a9d6d3194 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -3369,7 +3369,6 @@ export class CodexAcpServer { new Set([sessionId]), new Set(), isOpen, - this.subagentModels(sessionState).currentModel ?? undefined, ); return; } @@ -3419,12 +3418,13 @@ export class CodexAcpServer { ancestry: Set, unreadableChildren: Set, isOpen: () => boolean, - parentModel: string | undefined, ): Promise { const session = new ACPSessionConnection(this.connection, sessionId); const announced = new Map(); const requestedModels = new Map(); - // Sends the model of a replayed child after its announcement, as the live session does. + // Sends the model of a replayed child after its announcement, as the live session does. Replay sends only + // a model that the history names for the child: the current model of the parent can differ from the + // model at the spawn, so a child without one gets no model option. const reportModel = async (childSessionId: string, model: string | undefined) => { if (!model) return; const models = this.subagentModels(sessionState); @@ -3464,10 +3464,7 @@ export class CodexAcpServer { // An interaction that started no child turn is not a new generation. if (activityKind === "interacted" && !childItems) continue; const name = nameFromAgentPath(item.agentPath, `Agent ${item.agentThreadId.slice(-8)}`); - const model = item.model - || previous?.model - || requestedModels.get(item.agentThreadId) - || parentModel; + const model = item.model || previous?.model || requestedModels.get(item.agentThreadId); await session.update({ sessionUpdate: "subagent_spawned", subagentSessionId: childSessionId, @@ -3492,7 +3489,6 @@ export class CodexAcpServer { new Set([...ancestry, item.agentThreadId]), unreadableChildren, isOpen, - model, ); } catch (error) { @@ -3516,7 +3512,7 @@ export class CodexAcpServer { const child = announced.get(item.agentThreadId); if (!child) { const name = nameFromAgentPath(item.agentPath, `Agent ${item.agentThreadId.slice(-8)}`); - const model = item.model || requestedModels.get(item.agentThreadId) || parentModel; + const model = item.model || requestedModels.get(item.agentThreadId); await session.update({ sessionUpdate: "subagent_spawned", subagentSessionId: item.agentThreadId, diff --git a/src/__tests__/CodexACPAgent/load-session.test.ts b/src/__tests__/CodexACPAgent/load-session.test.ts index cb4aaf8fb..a9d91c0ef 100644 --- a/src/__tests__/CodexACPAgent/load-session.test.ts +++ b/src/__tests__/CodexACPAgent/load-session.test.ts @@ -455,6 +455,127 @@ describe("CodexACPAgent - loadSession", () => { ]); }); + it("replays the model of a child only when the history names it", async () => { + const fixture = createCodexMockTestFixture(); + const agent = fixture.getCodexAcpAgent(); + const client = fixture.getCodexAcpClient(); + const appServer = fixture.getCodexAppServerClient(); + client.readAuthRequirement = vi.fn().mockResolvedValue({required: false, account: null}); + client.getAccount = vi.fn().mockResolvedValue({account: null, requiresOpenaiAuth: false}); + client.listSkills = vi.fn().mockResolvedValue({data: []}); + const model = createTestModel(); + appServer.listModels = vi.fn().mockResolvedValue({data: [model], nextCursor: null}); + const turn = (id: string, items: Thread["turns"][number]["items"]): Thread["turns"][number] => ({ + id, + rootTurnId: null, + itemsView: "full", + status: "completed", + error: null, + startedAt: null, + completedAt: null, + durationMs: null, + items, + }); + const makeThread = (id: string, turns: Thread["turns"]): Thread => ({ + id, + sessionId: "root-history", + parentThreadId: id === "root-history" ? null : "root-history", + threadSource: null, + originator: null, + forkedFromId: null, + preview: id, + ephemeral: false, + modelProvider: "openai", + model: null, + reasoningEffort: null, + createdAt: 1, + updatedAt: 2, + recencyAt: null, + status: {type: "idle"}, + path: null, + cwd: "/workspace", + cliVersion: "0", + section: null, + sectionEnteredAt: null, + projectId: null, + historyMode: "legacy", + source: "cli", + agentNickname: null, + agentRole: null, + gitInfo: null, + name: null, + turns, + }); + const spawn = (childThreadId: string, requestedModel: string | null) => ({ + type: "collabAgentToolCall" as const, + id: `spawn-${childThreadId}`, + tool: "spawnAgent" as const, + status: "completed" as const, + senderThreadId: "root-history", + receiverThreadIds: [childThreadId], + prompt: "Review the diff", + model: requestedModel, + reasoningEffort: null, + agentsStates: {}, + }); + const started = (childThreadId: string, resolvedModel: string | null) => ({ + type: "subAgentActivity" as const, + model: resolvedModel, + reasoningEffort: null, + id: `started-${childThreadId}`, + kind: "started" as const, + agentThreadId: childThreadId, + agentPath: `/root/${childThreadId}`, + }); + const root = makeThread("root-history", [ + turn("root-turn-1", [ + spawn("requested", model.id), + started("requested", null), + started("resolved", "custom-model"), + // Only the current model of the root could name the model of this child. + spawn("inherited", null), + started("inherited", null), + ]), + ]); + appServer.threadResume = vi.fn().mockResolvedValue({ + thread: root, + model: model.id, + modelProvider: "openai", + cwd: "/workspace", + approvalPolicy: "never", + sandbox: {type: "dangerFullAccess"}, + reasoningEffort: model.defaultReasoningEffort, + }); + appServer.threadReadWithHistory = vi.fn().mockResolvedValue({thread: root}); + appServer.threadRead = vi.fn().mockImplementation(({threadId}: {threadId: string}) => + Promise.resolve({thread: makeThread(threadId, [turn(`${threadId}-turn`, [])])})); + + await agent.initialize({ + protocolVersion: 1, + clientCapabilities: { + _meta: {jetbrains: {air: {version: 1, capabilities: ["nativeSubagentSessions"]}}}, + }, + }); + await agent.loadSession({sessionId: root.id, cwd: "/workspace", mcpServers: []}); + + const updates = fixture.getAcpConnectionEvents([]) + .filter(event => event.method === "sessionUpdate") + .map(event => event.args[0]) + .filter(({update}) => update.sessionUpdate === "subagent_spawned" + || (update.sessionUpdate === "config_option_update" && update.configOptions[0]?.category === "model" + && update.configOptions.length === 1)) + .map(({sessionId, update}) => update.sessionUpdate === "subagent_spawned" + ? `${sessionId} subagent_spawned ${update.subagentSessionId}` + : `${sessionId} model ${update.configOptions[0].currentValue}`); + expect(updates).toEqual([ + "root-history subagent_spawned requested", + `requested model ${model.id}`, + "root-history subagent_spawned resolved", + "resolved model custom-model", + "root-history subagent_spawned inherited", + ]); + }); + it("should replay history during loadSession", async () => { const fixture = createCodexMockTestFixture(); const codexAcpAgent = fixture.getCodexAcpAgent(); From aaf001ef5ec1bd07f8d2df8f18ba948884102a01 Mon Sep 17 00:00:00 2001 From: Nikita Ashikhmin Date: Sun, 25 Oct 2026 21:11:58 -0700 Subject: [PATCH 4/9] fix: settle the subagent model at the spawn and keep its report optional - A spawn without a model takes the model of its parent at the spawn, so a root model change before the announcement or a reopen does not change the reported model. - A client without session configuration (JetBrains 2026.1) gets no model update for a subagent. - A parent without models has no model option, so the child keeps the full model ID as the value and the name. - A failed model report is logged and does not stop the announcement. - A thread model that gives the same option value sends no new update. --- src/CodexAcpServer.ts | 6 +- src/ModelConfigOption.ts | 3 +- .../CodexACPAgent/subagent-model.test.ts | 157 ++++++++++++++++-- src/subagents/CodexSubagentEventRouter.ts | 49 ++++-- 4 files changed, 185 insertions(+), 30 deletions(-) diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index a9d6d3194..6ec605066 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -3125,8 +3125,8 @@ export class CodexAcpServer { } /** The models of a root session, for the configuration of its native subagent sessions. */ - private subagentModels(sessionState: SessionState): SubagentModels { - if (!this.isSessionConfigEnabled()) return {currentModel: null, availableModels: []}; + private subagentModels(sessionState: SessionState): SubagentModels | null { + if (!this.isSessionConfigEnabled()) return null; let currentModel: string | null = null; try { currentModel = ModelId.fromString(sessionState.currentModelId).model; @@ -3426,8 +3426,8 @@ export class CodexAcpServer { // a model that the history names for the child: the current model of the parent can differ from the // model at the spawn, so a child without one gets no model option. const reportModel = async (childSessionId: string, model: string | undefined) => { - if (!model) return; const models = this.subagentModels(sessionState); + if (!model || !models) return; await session.update({ sessionUpdate: "config_option_update", configOptions: [createSubagentModelConfigOption(models.availableModels, models.currentModel, model)], diff --git a/src/ModelConfigOption.ts b/src/ModelConfigOption.ts index 21c2da652..429800d38 100644 --- a/src/ModelConfigOption.ts +++ b/src/ModelConfigOption.ts @@ -111,7 +111,8 @@ export function createSubagentModelConfigOption( parentBaseModelId: string | null, model: string, ): SessionConfigOption { - const parentOptions = modelOptions(availableModels, parentBaseModelId); + // Without models the parent session has no model option, so there is nothing to match. + const parentOptions = availableModels.length > 0 ? modelOptions(availableModels, parentBaseModelId) : []; // Codex can name a model by its `model` slug instead of its id. const id = availableModels.find(candidate => candidate.id === model)?.id ?? availableModels.find(candidate => candidate.model === model)?.id diff --git a/src/__tests__/CodexACPAgent/subagent-model.test.ts b/src/__tests__/CodexACPAgent/subagent-model.test.ts index 31b148f51..e919d849e 100644 --- a/src/__tests__/CodexACPAgent/subagent-model.test.ts +++ b/src/__tests__/CodexACPAgent/subagent-model.test.ts @@ -15,10 +15,14 @@ const MODELS: SubagentModels = { availableModels: [ createTestModel({id: "gpt-5.5", displayName: "gpt-5.5"}), createTestModel({id: "gpt-5.2-codex", model: "gpt-5.2-codex", displayName: "gpt-5.2-codex", isDefault: false}), + createTestModel({id: "codex-fast", model: "gpt-5.2-codex-fast", displayName: "gpt-5.2-codex-fast", isDefault: false}), ], }; -function v1Router(models: SubagentModels = MODELS): {router: CodexSubagentEventRouter; sent: SentUpdate[]} { +function v1Router( + models: SubagentModels | null = MODELS, + supported = true, +): {router: CodexSubagentEventRouter; sent: SentUpdate[]} { const sent: SentUpdate[] = []; const connection = { notify: vi.fn(async (_method: string, params: unknown) => { @@ -27,7 +31,7 @@ function v1Router(models: SubagentModels = MODELS): {router: CodexSubagentEventR request: vi.fn(), } as unknown as AcpClientConnection; const session = new ACPSessionConnection(connection, "root"); - return {router: new CodexSubagentEventRouter("root", true, session, () => {}, () => models), sent}; + return {router: new CodexSubagentEventRouter("root", supported, session, () => {}, () => models), sent}; } function v2Router(): {router: CodexSubagentEventRouter; sent: SentUpdate[]} { @@ -45,31 +49,31 @@ function v2Router(): {router: CodexSubagentEventRouter; sent: SentUpdate[]} { return {router: new CodexSubagentEventRouter("root", true, session, () => {}, () => MODELS), sent}; } -function spawn(model: string | null): ServerNotification { +function spawn(model: string | null, child = "child-1", sender = "root"): ServerNotification { const item: ThreadItem = { type: "collabAgentToolCall", - id: "spawn-1", + id: `spawn-${child}`, tool: "spawnAgent", status: "inProgress", - senderThreadId: "root", - receiverThreadIds: ["child-1"], + senderThreadId: sender, + receiverThreadIds: [child], prompt: "Review the diff", model, reasoningEffort: null, - agentsStates: {"child-1": {status: "running", message: null}}, + agentsStates: {[child]: {status: "running", message: null}}, }; return {method: "item/started", params: {threadId: "root", turnId: "turn-1", startedAtMs: 0, item}}; } -function activity(model: string | null): ServerNotification { +function activity(model: string | null, child = "child-1", path = "/root/reviewer"): ServerNotification { const item: ThreadItem = { type: "subAgentActivity", model, reasoningEffort: null, - id: "activity-started", + id: `activity-${child}`, kind: "started", - agentThreadId: "child-1", - agentPath: "/root/reviewer", + agentThreadId: child, + agentPath: path, }; return {method: "item/started", params: {threadId: "root", turnId: "turn-1", startedAtMs: 0, item}}; } @@ -95,6 +99,20 @@ function modelOptionOf(update: Record): Record return (update["configOptions"] as Array>)[0]!; } +function threadStarted(threadId: string, model: string): ServerNotification { + return {method: "thread/started", params: {thread: {id: threadId, model} as never}}; +} + +/** The model updates as `session value "name"`. */ +function modelReports(sent: SentUpdate[]): string[] { + return sent + .filter(({update}) => update["sessionUpdate"] === "config_option_update") + .map(({sessionId, update}) => { + const option = modelOptionOf(update) as {currentValue: string; options: Array<{name: string}>}; + return `${sessionId} ${option.currentValue} "${option.options[0]!.name}"`; + }); +} + describe('the model of a native subagent session', () => { afterEach(() => { expectConformingV2SessionUpdates(); @@ -196,4 +214,121 @@ describe('the model of a native subagent session', () => { await expect(JSON.stringify(sent, null, 2)).toMatchFileSnapshot('data/subagent-model-v2.json'); }); + + it('keeps the model of the parent at the spawn when the root model changes before a reopen', async () => { + const models = {...MODELS}; + const {router, sent} = v1Router(models); + + await router.handle(spawn(null)); + // The child ends before Codex reports its activity, so it is not announced. + await router.handle(childTurn("first-turn", "completed")); + models.currentModel = "gpt-5.2-codex"; + await router.handle(childTurn("followup-turn", "inProgress")); + + expect(modelReports(sent)).toEqual(['child-1:generation:2 gpt-5.5 "5.5"']); + }); + + it('keeps the model of the parent at the spawn when the root model changes before the announcement', async () => { + const models = {...MODELS}; + const {router, sent} = v1Router(models); + + await router.handle(spawn(null)); + models.currentModel = "gpt-5.2-codex"; + await router.handle(activity(null)); + + expect(modelReports(sent)).toEqual(['child-1 gpt-5.5 "5.5"']); + }); + + it('gives a nested child that requests no model the model of its parent child', async () => { + const {router, sent} = v1Router(); + + await router.handle(spawn("gpt-5.2-codex")); + await router.handle(activity(null)); + await router.handle(spawn(null, "grandchild-1", "child-1")); + await router.handle(activity(null, "grandchild-1", "/root/reviewer/helper")); + + expect(modelReports(sent)).toEqual([ + 'child-1 gpt-5.2-codex "5.2 Codex"', + 'grandchild-1 gpt-5.2-codex "5.2 Codex"', + ]); + }); + + it('sends no configuration to a client that gets no session configuration', async () => { + const {router, sent} = v1Router(null); + + await router.handle(spawn("gpt-5.2-codex")); + await router.handle(threadStarted("child-1", "gpt-5.5")); + await router.handle(activity("custom-model")); + await router.handle(threadStarted("child-1", "gpt-5.2-codex")); + + expect(sent.map(({update}) => update["sessionUpdate"])).toEqual(["subagent_spawned"]); + }); + + it('sends nothing to a client without native subagent sessions', async () => { + const {router, sent} = v1Router(MODELS, false); + + await router.handle(spawn("gpt-5.2-codex")); + await router.handle(activity(null)); + await router.handle(threadStarted("child-1", "gpt-5.5")); + + expect(sent).toEqual([]); + }); + + it('takes the model from thread/started, before and after the announcement', async () => { + const {router, sent} = v1Router(); + + await router.handle(spawn(null)); + await router.handle(threadStarted("child-1", "gpt-5.2-codex")); + await router.handle(activity(null)); + await router.handle(threadStarted("child-1", "gpt-5.5")); + + expect(modelReports(sent)).toEqual([ + 'child-1 gpt-5.2-codex "5.2 Codex"', + 'child-1 gpt-5.5 "5.5"', + ]); + }); + + it('sends no new snapshot when another spelling of the model gives the same option value', async () => { + const {router, sent} = v1Router(); + + await router.handle(spawn("codex-fast")); + await router.handle(activity(null)); + await router.handle(threadStarted("child-1", "gpt-5.2-codex-fast")); + + expect(modelReports(sent)).toEqual(['child-1 codex-fast "5.2 Codex Fast"']); + }); + + it('keeps the full model id when the parent session has no model option', async () => { + const {router, sent} = v1Router({currentModel: "gpt-5.5", availableModels: []}); + + await router.handle(spawn(null)); + await router.handle(activity(null)); + + expect(modelReports(sent)).toEqual(['child-1 gpt-5.5 "gpt-5.5"']); + }); + + it('announces the child when the model report fails', async () => { + const sent: string[] = []; + const connection = { + notify: vi.fn(async (_method: string, params: unknown) => { + const update = (params as SentUpdate).update; + if (update["sessionUpdate"] === "config_option_update") throw new Error("connection closed"); + sent.push(String(update["sessionUpdate"])); + }), + request: vi.fn(), + } as unknown as AcpClientConnection; + const router = new CodexSubagentEventRouter( + "root", + true, + new ACPSessionConnection(connection, "root"), + () => {}, + () => MODELS, + ); + + await router.handle(spawn("gpt-5.2-codex")); + await router.handle(activity(null)); + + expect(sent).toEqual(["subagent_spawned"]); + expect(await router.waitForMaterializedSession("child-1")).toBe("child-1"); + }); }); diff --git a/src/subagents/CodexSubagentEventRouter.ts b/src/subagents/CodexSubagentEventRouter.ts index ec43d247d..7c4d10de9 100644 --- a/src/subagents/CodexSubagentEventRouter.ts +++ b/src/subagents/CodexSubagentEventRouter.ts @@ -27,7 +27,10 @@ type PendingSubagent = { parentThreadId: string; parentSessionId: string; task: string; - /** The model that the spawn requested, or the model of the child thread when Codex reported it. */ + /** + * The model of the child thread when Codex reported it, else the model that the spawn requested, else the model + * of the parent at the spawn. + */ model?: string; buffered: PendingNotificationBuffer; }; @@ -67,14 +70,15 @@ export class CodexSubagentEventRouter { /** * @param onChildSessionEnded runs when a native child session ends, so that the owner can release * the state that it keeps per child session. - * @param rootModels gives the models of the root session when a child needs them. + * @param rootModels gives the models of the root session when a child needs them, or `null` when the client + * gets no session configuration. */ constructor( private readonly rootSessionId: string, private readonly supported: boolean, private readonly session: ACPSessionConnection, private readonly onChildSessionEnded: (sessionId: string) => void, - private readonly rootModels: () => SubagentModels, + private readonly rootModels: () => SubagentModels | null, ) {} async handle(notification: ServerNotification): Promise { @@ -171,11 +175,13 @@ export class CodexSubagentEventRouter { representedSpawn = true; continue; } + // A spawn without a model runs with the model that its parent has now, not at the announcement. + const model = item.model || this.inheritedModel(parentThreadId); this.pendingSpawns.set(childSessionId, { parentThreadId, parentSessionId, task: item.prompt?.trim() || "Delegated task", - ...(item.model ? {model: item.model} : {}), + ...(model ? {model} : {}), buffered: new PendingNotificationBuffer(childSessionId), }); representedSpawn = true; @@ -376,10 +382,10 @@ export class CodexSubagentEventRouter { generation: 1, }); this.pendingSpawns.delete(childSessionId); - await this.reportModel(childSessionId, model); // A loop, not push(...): a spread of a large buffer exceeds the argument limit and throws. for (const notification of pending?.buffered.take() ?? []) this.replayQueue.push(notification); this.resolveMaterialization(childSessionId, childSessionId); + await this.reportModel(childSessionId, model); } private finishPending(childSessionId: string): void { @@ -423,14 +429,13 @@ export class CodexSubagentEventRouter { const pending = this.terminalPendingSpawns.get(childThreadId); if (!pending) return; const parentSessionId = this.children.get(pending.parentThreadId)?.sessionId ?? this.rootSessionId; - const model = pending.model ?? this.inheritedModel(pending.parentThreadId); const reopened: NativeSubagent = { parentThreadId: pending.parentThreadId, parentSessionId, sessionId: `${childThreadId}:generation:2`, name: fallbackName(childThreadId), task: pending.task, - ...(model ? {model} : {}), + ...(pending.model ? {model: pending.model} : {}), generation: 2, }; await this.session.update({ @@ -486,29 +491,43 @@ export class CodexSubagentEventRouter { return; } const child = this.children.get(threadId); - if (!child || child.model === model) return; + if (!child) return; + const changed = !child.model || this.modelValue(child.model) !== this.modelValue(model); child.model = model; - if (child.terminalState === undefined) await this.reportModel(child.sessionId, model); + if (changed && child.terminalState === undefined) await this.reportModel(child.sessionId, model); } /** A child that requests no model runs with the model of its parent. */ private inheritedModel(parentThreadId: string): string | undefined { const parent = this.children.get(parentThreadId); if (parent) return parent.model; - return this.rootModels().currentModel ?? undefined; + return this.rootModels()?.currentModel ?? undefined; + } + + /** The value of the model option for a model, which can differ from the model string that Codex reports. */ + private modelValue(model: string): string { + const models = this.rootModels(); + const option = createSubagentModelConfigOption(models?.availableModels ?? [], models?.currentModel ?? null, model); + return String(option.currentValue); } /** * Sends the configuration of a child session after its announcement. The client may not change it, so the * model option offers only the current model. A child whose model is not known gets no configuration. + * The report is optional, so its failure does not stop the child lifecycle. */ private async reportModel(childSessionId: string, model: string | undefined): Promise { - if (!model) return; const models = this.rootModels(); - await this.session.update({ - sessionUpdate: "config_option_update", - configOptions: [createSubagentModelConfigOption(models.availableModels, models.currentModel, model)], - }, childSessionId); + if (!model || !models) return; + try { + await this.session.update({ + sessionUpdate: "config_option_update", + configOptions: [createSubagentModelConfigOption(models.availableModels, models.currentModel, model)], + }, childSessionId); + } + catch (error) { + logger.error(`Failed to report the model of subagent session ${childSessionId}`, error); + } } private resolveMaterialization(childThreadId: string, sessionId: string | null): void { From b4c61901b734f5c5f0d4cef0cea501768654872f Mon Sep 17 00:00:00 2001 From: Nikita Ashikhmin Date: Sun, 25 Oct 2026 21:11:59 -0700 Subject: [PATCH 5/9] test: check the replayed subagent model on ACP v1 and v2 --- .../CodexACPAgent/load-session.test.ts | 6 +-- .../v2-resume-replay-subagents.test.ts | 44 +++++++++++++++++++ 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/src/__tests__/CodexACPAgent/load-session.test.ts b/src/__tests__/CodexACPAgent/load-session.test.ts index a9d91c0ef..65122c838 100644 --- a/src/__tests__/CodexACPAgent/load-session.test.ts +++ b/src/__tests__/CodexACPAgent/load-session.test.ts @@ -566,12 +566,12 @@ describe("CodexACPAgent - loadSession", () => { && update.configOptions.length === 1)) .map(({sessionId, update}) => update.sessionUpdate === "subagent_spawned" ? `${sessionId} subagent_spawned ${update.subagentSessionId}` - : `${sessionId} model ${update.configOptions[0].currentValue}`); + : `${sessionId} model ${update.configOptions[0].currentValue} "${update.configOptions[0].options[0].name}"`); expect(updates).toEqual([ "root-history subagent_spawned requested", - `requested model ${model.id}`, + `requested model ${model.id} "${model.id}"`, "root-history subagent_spawned resolved", - "resolved model custom-model", + `resolved model custom-model "custom-model"`, "root-history subagent_spawned inherited", ]); }); diff --git a/src/__tests__/CodexACPAgent/v2-resume-replay-subagents.test.ts b/src/__tests__/CodexACPAgent/v2-resume-replay-subagents.test.ts index f8d148b4b..b19b26873 100644 --- a/src/__tests__/CodexACPAgent/v2-resume-replay-subagents.test.ts +++ b/src/__tests__/CodexACPAgent/v2-resume-replay-subagents.test.ts @@ -238,6 +238,50 @@ describe('ACP v2 resume replay: native subagent history (nativeSubagentSessions expect(agentMessages).toContain("Investigating the flaky test."); }); + it.each([ + {requestedModel: "gpt-5", expected: [{sessionId: childId, value: "gpt-5", name: "5"}]}, + // Only the current model of the root could name the model of this child, so replay sends no model. + {requestedModel: null, expected: []}, + ])('replays the model of a child only when the history names it ($requestedModel)', async ({requestedModel, expected}) => { + const root: Thread["turns"] = [{ + ...rootTurns[0]!, + items: [ + { + type: "collabAgentToolCall", + id: "spawn-1", + tool: "spawnAgent", + status: "completed", + senderThreadId: rootId, + receiverThreadIds: [childId], + prompt: "Research", + model: requestedModel, + reasoningEffort: null, + agentsStates: {}, + }, + ...rootTurns[0]!.items, + ], + }]; + const {connection, updates} = await connectV2Client({ + _meta: {jetbrains: {air: {version: 1, capabilities: [AIR_NATIVE_SUBAGENT_SESSIONS_KEY]}}}, + }, {root, child: childTurns}); + closeClient = () => connection.close(); + + await connection.agent.request(acpV2.methods.agent.session.resume, { + sessionId: rootId, + cwd, + replayFrom: {type: "start"}, + }); + + const childModels = updates + .filter(({sessionId, update}) => sessionId === childId && update.sessionUpdate === "config_option_update") + .map(({sessionId, update}) => { + const option = (update as {configOptions: Array<{currentValue: string; options: Array<{name: string}>}>}) + .configOptions[0]!; + return {sessionId, value: option.currentValue, name: option.options[0]!.name}; + }); + expect(childModels).toEqual(expected); + }); + it('replays a follow-up turn of a finished child as a new generation', async () => { const turn = (id: string, items: Thread["turns"][number]["items"]): Thread["turns"][number] => ({ id, From 2f87f594264ca367024df26b1108b63165bda564 Mon Sep 17 00:00:00 2001 From: Nikita Ashikhmin Date: Sun, 25 Oct 2026 21:26:41 -0700 Subject: [PATCH 6/9] fix: rank the sources of a subagent model The router now keeps the source of each child model. At the creation the order is the subAgentActivity model, then the thread/started model, then the spawnAgent model, then the model of the parent. A report from a lower source does not replace a higher one, so a thread/started of a resume keeps the activity model. A thread/settings/updated is a real model change and always wins. - A settings change of a pending child is read before the router buffers it, so the announcement has that model and the replay sends no copy. - A thread/started that arrives before the spawn names its child is kept until the spawn. The root turn end clears these models. - A child that requests no model inherits the root model of the running turn, not a model that the client set during the turn. --- .../CodexACPAgent/subagent-model.test.ts | 109 +++++++++++++--- src/subagents/CodexSubagentEventRouter.ts | 119 +++++++++++++----- 2 files changed, 180 insertions(+), 48 deletions(-) diff --git a/src/__tests__/CodexACPAgent/subagent-model.test.ts b/src/__tests__/CodexACPAgent/subagent-model.test.ts index e919d849e..a5de6eef9 100644 --- a/src/__tests__/CodexACPAgent/subagent-model.test.ts +++ b/src/__tests__/CodexACPAgent/subagent-model.test.ts @@ -49,20 +49,28 @@ function v2Router(): {router: CodexSubagentEventRouter; sent: SentUpdate[]} { return {router: new CodexSubagentEventRouter("root", true, session, () => {}, () => MODELS), sent}; } -function spawn(model: string | null, child = "child-1", sender = "root"): ServerNotification { +function spawn( + model: string | null, + child = "child-1", + sender = "root", + receivers = [child], + method: "item/started" | "item/completed" = "item/started", +): ServerNotification { const item: ThreadItem = { type: "collabAgentToolCall", id: `spawn-${child}`, tool: "spawnAgent", - status: "inProgress", + status: method === "item/started" ? "inProgress" : "completed", senderThreadId: sender, - receiverThreadIds: [child], + receiverThreadIds: receivers, prompt: "Review the diff", model, reasoningEffort: null, - agentsStates: {[child]: {status: "running", message: null}}, + agentsStates: Object.fromEntries(receivers.map(id => [id, {status: "running" as const, message: null}])), }; - return {method: "item/started", params: {threadId: "root", turnId: "turn-1", startedAtMs: 0, item}}; + return method === "item/started" + ? {method, params: {threadId: "root", turnId: "turn-1", startedAtMs: 0, item}} + : {method, params: {threadId: "root", turnId: "turn-1", completedAtMs: 0, item}}; } function activity(model: string | null, child = "child-1", path = "/root/reviewer"): ServerNotification { @@ -99,6 +107,35 @@ function modelOptionOf(update: Record): Record return (update["configOptions"] as Array>)[0]!; } +/** Handles the notifications in order, with the child updates that the router buffered, as the event handler does. */ +async function feed(router: CodexSubagentEventRouter, ...notifications: ServerNotification[]): Promise { + for (const notification of notifications) { + await router.handle(notification); + for (const buffered of router.takeBufferedNotifications()) await router.handle(buffered); + } +} + +function settings(model: string): ServerNotification { + return {method: "thread/settings/updated", params: {threadId: "child-1", threadSettings: {model} as never}}; +} + +function rootTurn(status: "completed" | "inProgress"): ServerNotification { + const turn = { + id: "root-turn", + items: [], + rootTurnId: null, + itemsView: "notLoaded" as const, + status, + error: null, + startedAt: null, + completedAt: null, + durationMs: null, + }; + return status === "completed" + ? {method: "turn/completed", params: {threadId: "root", turn}} + : {method: "turn/started", params: {threadId: "root", turn}}; +} + function threadStarted(threadId: string, model: string): ServerNotification { return {method: "thread/started", params: {thread: {id: threadId, model} as never}}; } @@ -274,18 +311,62 @@ describe('the model of a native subagent session', () => { expect(sent).toEqual([]); }); - it('takes the model from thread/started, before and after the announcement', async () => { + it('takes the model from thread/started when the activity names none', async () => { const {router, sent} = v1Router(); - await router.handle(spawn(null)); - await router.handle(threadStarted("child-1", "gpt-5.2-codex")); - await router.handle(activity(null)); - await router.handle(threadStarted("child-1", "gpt-5.5")); + await feed(router, spawn(null), threadStarted("child-1", "gpt-5.2-codex"), activity(null)); - expect(modelReports(sent)).toEqual([ - 'child-1 gpt-5.2-codex "5.2 Codex"', - 'child-1 gpt-5.5 "5.5"', - ]); + expect(modelReports(sent)).toEqual(['child-1 gpt-5.2-codex "5.2 Codex"']); + }); + + it('keeps the activity model when a later thread/started reports another model', async () => { + const {router, sent} = v1Router(); + + await feed(router, spawn(null), activity("gpt-5.2-codex"), threadStarted("child-1", "gpt-5.5")); + + expect(modelReports(sent)).toEqual(['child-1 gpt-5.2-codex "5.2 Codex"']); + }); + + it('takes a model from thread/started that arrives before the spawn names its child', async () => { + const {router, sent} = v1Router(); + + await feed( + router, + spawn(null, "child-1", "root", []), + threadStarted("child-1", "gpt-5.2-codex"), + spawn(null, "child-1", "root", ["child-1"], "item/completed"), + activity(null), + ); + + expect(modelReports(sent)).toEqual(['child-1 gpt-5.2-codex "5.2 Codex"']); + }); + + it('announces a pending child with the model of a settings change, and reports it once', async () => { + const {router, sent} = v1Router(); + + await feed(router, spawn("gpt-5.5"), settings("gpt-5.2-codex"), activity(null)); + + expect(modelReports(sent)).toEqual(['child-1 gpt-5.2-codex "5.2 Codex"']); + }); + + it('reports a settings change after the announcement over the activity model', async () => { + const {router, sent} = v1Router(); + + await feed(router, spawn(null), activity("gpt-5.2-codex"), settings("gpt-5.5")); + + expect(modelReports(sent)).toEqual(['child-1 gpt-5.2-codex "5.2 Codex"', 'child-1 gpt-5.5 "5.5"']); + }); + + it('inherits the root model of the running turn, not a model that the client set during the turn', async () => { + const models = {...MODELS}; + const {router, sent} = v1Router(models); + + await feed(router, rootTurn("inProgress")); + models.currentModel = "gpt-5.2-codex"; + await feed(router, spawn(null), activity(null), rootTurn("completed")); + await feed(router, rootTurn("inProgress"), spawn(null, "child-2"), activity(null, "child-2", "/root/second")); + + expect(modelReports(sent)).toEqual(['child-1 gpt-5.5 "5.5"', 'child-2 gpt-5.2-codex "5.2 Codex"']); }); it('sends no new snapshot when another spelling of the model gives the same option value', async () => { diff --git a/src/subagents/CodexSubagentEventRouter.ts b/src/subagents/CodexSubagentEventRouter.ts index 7c4d10de9..0288a4b33 100644 --- a/src/subagents/CodexSubagentEventRouter.ts +++ b/src/subagents/CodexSubagentEventRouter.ts @@ -18,7 +18,7 @@ type NativeSubagent = { task: string; path?: string; /** The model of the child, or `undefined` when the adapter does not know it. */ - model?: string; + model?: ChildModel; generation: number; terminalState?: SubagentState; }; @@ -27,11 +27,7 @@ type PendingSubagent = { parentThreadId: string; parentSessionId: string; task: string; - /** - * The model of the child thread when Codex reported it, else the model that the spawn requested, else the model - * of the parent at the spawn. - */ - model?: string; + model?: ChildModel; buffered: PendingNotificationBuffer; }; @@ -39,9 +35,28 @@ type PendingSubagent = { type EndedSpawn = { parentThreadId: string; task: string; - model?: string; + model?: ChildModel; }; +/** + * Where the adapter learned the model of a child. A source of a higher rank wins: a settings change is a real + * change of the model, and at the creation the activity item names the resolved model. + */ +const MODEL_SOURCE_RANK = {inherited: 0, requested: 1, threadStarted: 2, activity: 3, settings: 4} as const; + +type ChildModel = { + model: string; + source: keyof typeof MODEL_SOURCE_RANK; +}; + +/** A report of a lower rank does not replace the model, so a `thread/started` of a resume keeps the activity model. */ +function preferredModel(current: ChildModel | undefined, next: ChildModel): ChildModel; +function preferredModel(current: ChildModel | undefined, next: ChildModel | undefined): ChildModel | undefined; +function preferredModel(current: ChildModel | undefined, next: ChildModel | undefined): ChildModel | undefined { + if (!current || !next) return current ?? next; + return MODEL_SOURCE_RANK[next.source] >= MODEL_SOURCE_RANK[current.source] ? next : current; +} + /** The models of the root session, which a child that does not request a model inherits. */ export type SubagentModels = { /** The base model id of the root session, or `null` when it is not known. */ @@ -66,6 +81,10 @@ export class CodexSubagentEventRouter { private readonly materializationWaiters = new Map void>>(); private readonly replayQueue: ServerNotification[] = []; private readonly activeLegacyActivities = new Set(); + /** Models that Codex reported for threads before their spawn. The root turn end clears them. */ + private readonly earlyThreadModels = new Map(); + /** The root model when the current root turn started, or `undefined` outside a root turn. */ + private rootTurnModel: string | null | undefined = undefined; /** * @param onChildSessionEnded runs when a native child session ends, so that the owner can release @@ -84,16 +103,31 @@ export class CodexSubagentEventRouter { async handle(notification: ServerNotification): Promise { if (notification.method === "thread/started") { const thread = notification.params.thread; - if (thread.model) await this.applyThreadModel(thread.id, thread.model); + if (thread.model) await this.applyThreadModel(thread.id, {model: thread.model, source: "threadStarted"}); return false; } + if (notification.method === "thread/settings/updated") { + // Read before the buffering below, so that a pending child announces with this model. + await this.applyThreadModel(notification.params.threadId, { + model: notification.params.threadSettings.model, + source: "settings", + }); + } if (notification.method === "turn/started") { + // The running turn keeps its model when the client changes the session model. + if (notification.params.threadId === this.rootSessionId) { + this.rootTurnModel = this.rootModels()?.currentModel ?? null; + } const childTurn = this.isKnownChild(notification.params.threadId); // A follow-up can start a new turn of an ended child with no collaboration item that reports it. if (childTurn) await this.reopen(notification.params.threadId); return childTurn; } if (notification.method === "turn/completed") { + if (notification.params.threadId === this.rootSessionId) { + this.rootTurnModel = undefined; + this.earlyThreadModels.clear(); + } const childTurn = this.isKnownChild(notification.params.threadId); const state = terminalStateFromTurn(notification.params.turn.status); if (!state) return childTurn; @@ -115,10 +149,6 @@ export class CodexSubagentEventRouter { this.pendingSpawns.get(notificationThreadId)!.buffered.push(notification); return true; } - if (notification.method === "thread/settings/updated") { - await this.applyThreadModel(notification.params.threadId, notification.params.threadSettings.model); - return false; - } if (notification.method !== "item/started" && notification.method !== "item/completed") { return false; } @@ -133,7 +163,10 @@ export class CodexSubagentEventRouter { // Codex reports the root participant through the same activity item // shape as children. It is the parent conversation, not a subagent. if (isRootAgentPath(item.agentPath)) return true; - if (this.terminalPendingSpawns.has(item.agentThreadId)) return true; + if (this.terminalPendingSpawns.has(item.agentThreadId)) { + if (item.model) await this.applyThreadModel(item.agentThreadId, {model: item.model, source: "activity"}); + return true; + } let hasNativeRepresentation = this.children.has(item.agentThreadId); if (!hasNativeRepresentation) { await this.materialize(item.agentThreadId, item.agentPath, item.model); @@ -175,8 +208,8 @@ export class CodexSubagentEventRouter { representedSpawn = true; continue; } - // A spawn without a model runs with the model that its parent has now, not at the announcement. - const model = item.model || this.inheritedModel(parentThreadId); + // The model is settled at the spawn, so a later change of the parent model does not change it. + const model = preferredModel(this.spawnModel(item.model, parentThreadId), this.takeEarlyModel(childSessionId)); this.pendingSpawns.set(childSessionId, { parentThreadId, parentSessionId, @@ -363,7 +396,10 @@ export class CodexSubagentEventRouter { const parentThreadId = pending?.parentThreadId ?? inferredParent.threadId; const parentSessionId = pending?.parentSessionId ?? inferredParent.sessionId; const task = pending?.task ?? `Delegated task for ${name}`; - const model = resolvedModel || pending?.model || this.inheritedModel(parentThreadId); + const model = preferredModel( + pending ? pending.model : preferredModel(this.spawnModel(null, parentThreadId), this.takeEarlyModel(childSessionId)), + resolvedModel ? {model: resolvedModel, source: "activity"} : undefined, + ); await this.session.update({ sessionUpdate: "subagent_spawned", subagentSessionId: childSessionId, @@ -385,7 +421,7 @@ export class CodexSubagentEventRouter { // A loop, not push(...): a spread of a large buffer exceeds the argument limit and throws. for (const notification of pending?.buffered.take() ?? []) this.replayQueue.push(notification); this.resolveMaterialization(childSessionId, childSessionId); - await this.reportModel(childSessionId, model); + await this.reportModel(childSessionId, model?.model); } private finishPending(childSessionId: string): void { @@ -447,7 +483,7 @@ export class CodexSubagentEventRouter { }, parentSessionId); this.children.set(childThreadId, reopened); this.terminalPendingSpawns.delete(childThreadId); - await this.reportModel(reopened.sessionId, reopened.model); + await this.reportModel(reopened.sessionId, reopened.model?.model); return; } if (!child.terminalState) return; @@ -474,34 +510,49 @@ export class CodexSubagentEventRouter { child.terminalState = previousState; throw error; } - await this.reportModel(child.sessionId, child.model); + await this.reportModel(child.sessionId, child.model?.model); } - /** Takes the model that Codex reports for a child thread. It replaces a requested or inherited model. */ - private async applyThreadModel(threadId: string, model: string): Promise { - if (threadId === this.rootSessionId) return; - const pending = this.pendingSpawns.get(threadId); + /** Takes a model that Codex reports for a child thread, when its source does not rank below the known one. */ + private async applyThreadModel(threadId: string, model: ChildModel): Promise { + if (!this.supported || threadId === this.rootSessionId) return; + const pending = this.pendingSpawns.get(threadId) ?? this.terminalPendingSpawns.get(threadId); if (pending) { - pending.model = model; + pending.model = preferredModel(pending.model, model); return; } - const ended = this.terminalPendingSpawns.get(threadId); - if (ended) { - ended.model = model; + const child = this.children.get(threadId); + if (!child) { + // A spawn can name its child only when it completes, after the child thread started. + this.earlyThreadModels.set(threadId, preferredModel(this.earlyThreadModels.get(threadId), model)); return; } - const child = this.children.get(threadId); - if (!child) return; - const changed = !child.model || this.modelValue(child.model) !== this.modelValue(model); - child.model = model; - if (changed && child.terminalState === undefined) await this.reportModel(child.sessionId, model); + const previous = child.model; + child.model = preferredModel(previous, model); + if (child.model === previous || child.terminalState !== undefined) return; + if (previous && this.modelValue(previous.model) === this.modelValue(model.model)) return; + await this.reportModel(child.sessionId, model.model); + } + + private takeEarlyModel(threadId: string): ChildModel | undefined { + const model = this.earlyThreadModels.get(threadId); + this.earlyThreadModels.delete(threadId); + return model; + } + + /** The model that a spawn requested, else the model of its parent. */ + private spawnModel(requested: string | null, parentThreadId: string): ChildModel | undefined { + if (requested) return {model: requested, source: "requested"}; + const inherited = this.inheritedModel(parentThreadId); + return inherited ? {model: inherited, source: "inherited"} : undefined; } /** A child that requests no model runs with the model of its parent. */ private inheritedModel(parentThreadId: string): string | undefined { const parent = this.children.get(parentThreadId); - if (parent) return parent.model; - return this.rootModels()?.currentModel ?? undefined; + if (parent) return parent.model?.model; + const rootModel = this.rootTurnModel !== undefined ? this.rootTurnModel : this.rootModels()?.currentModel; + return rootModel ?? undefined; } /** The value of the model option for a model, which can differ from the model string that Codex reports. */ From 1c80113f13b525783c1b7bfbbaaf2ca6ee820b76 Mon Sep 17 00:00:00 2001 From: Nikita Ashikhmin Date: Sun, 25 Oct 2026 21:26:41 -0700 Subject: [PATCH 7/9] fix: log a failed subagent model report during history replay --- src/CodexAcpServer.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index 6ec605066..093d01055 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -3428,10 +3428,15 @@ export class CodexAcpServer { const reportModel = async (childSessionId: string, model: string | undefined) => { const models = this.subagentModels(sessionState); if (!model || !models) return; - await session.update({ - sessionUpdate: "config_option_update", - configOptions: [createSubagentModelConfigOption(models.availableModels, models.currentModel, model)], - }, childSessionId); + try { + await session.update({ + sessionUpdate: "config_option_update", + configOptions: [createSubagentModelConfigOption(models.availableModels, models.currentModel, model)], + }, childSessionId); + } + catch (error) { + logger.error(`Failed to report the model of subagent session ${childSessionId}`, error); + } }; // v2 only (this path also serves v1's native replay, unchanged there). const startedReplayMessages = new Set(); From d949fe5aca44e9de6c715c8843cdf7c07e4c5880 Mon Sep 17 00:00:00 2001 From: Nikita Ashikhmin Date: Sun, 25 Oct 2026 21:33:09 -0700 Subject: [PATCH 8/9] fix: report only the subagent models that Codex states The adapter no longer guesses the model of a child from the spawnAgent request or from the parent. A role config can override the requested model, and the parent model can differ from the child model. The model now comes only from what Codex states about the child: the resolved subAgentActivity model, the model of the child thread in thread/started, and a thread/settings/updated change, ranked in that order. A child without a stated model gets no model update, which the subagents RFD allows. History replay sends only the activity model of the child. Codex 0.164 sets the subAgentActivity model of a multi-agent v2 spawn to the resolved model of the child, and only these activities announce a native subagent session. So a live child does not lose its model. --- src/CodexAcpServer.ts | 17 +- ...sted.json => subagent-model-activity.json} | 0 .../data/subagent-model-inherited.json | 35 ---- .../CodexACPAgent/load-session.test.ts | 18 +- .../CodexACPAgent/subagent-model.test.ts | 186 ++++++------------ .../v2-resume-replay-subagents.test.ts | 13 +- .../scenarios/client-profiles.test.ts | 4 - .../scenarios/data/air/collab-controls.jsonl | 1 - .../data/air/late-subagent-update.jsonl | 1 - .../data/air/native-subagent-session.jsonl | 1 - .../data/air/nested-subagent-session.jsonl | 2 - .../data/air/subagent-activity.jsonl | 1 - src/subagents/CodexSubagentEventRouter.ts | 40 +--- 13 files changed, 92 insertions(+), 227 deletions(-) rename src/__tests__/CodexACPAgent/data/{subagent-model-requested.json => subagent-model-activity.json} (100%) delete mode 100644 src/__tests__/CodexACPAgent/data/subagent-model-inherited.json diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index 093d01055..a8e3e2bcf 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -3132,7 +3132,7 @@ export class CodexAcpServer { currentModel = ModelId.fromString(sessionState.currentModelId).model; } catch { - // A session without a known model has no model to give its subagents. + // A session without a known model gives no current entry to match. } return {currentModel, availableModels: sessionState.availableModels}; } @@ -3421,10 +3421,8 @@ export class CodexAcpServer { ): Promise { const session = new ACPSessionConnection(this.connection, sessionId); const announced = new Map(); - const requestedModels = new Map(); // Sends the model of a replayed child after its announcement, as the live session does. Replay sends only - // a model that the history names for the child: the current model of the parent can differ from the - // model at the spawn, so a child without one gets no model option. + // the resolved model that the activity items name for the child, so an older record gets no model option. const reportModel = async (childSessionId: string, model: string | undefined) => { const models = this.subagentModels(sessionState); if (!model || !models) return; @@ -3469,7 +3467,7 @@ export class CodexAcpServer { // An interaction that started no child turn is not a new generation. if (activityKind === "interacted" && !childItems) continue; const name = nameFromAgentPath(item.agentPath, `Agent ${item.agentThreadId.slice(-8)}`); - const model = item.model || previous?.model || requestedModels.get(item.agentThreadId); + const model = item.model || previous?.model; await session.update({ sessionUpdate: "subagent_spawned", subagentSessionId: childSessionId, @@ -3517,7 +3515,7 @@ export class CodexAcpServer { const child = announced.get(item.agentThreadId); if (!child) { const name = nameFromAgentPath(item.agentPath, `Agent ${item.agentThreadId.slice(-8)}`); - const model = item.model || requestedModels.get(item.agentThreadId); + const model = item.model ?? undefined; await session.update({ sessionUpdate: "subagent_spawned", subagentSessionId: item.agentThreadId, @@ -3545,12 +3543,7 @@ export class CodexAcpServer { continue; } // The activity items above replay the lifecycle of a spawn. A control call is a tool call, as in the live session. - if (item.type === "collabAgentToolCall" && item.tool === "spawnAgent") { - if (item.model) { - for (const childThreadId of item.receiverThreadIds) requestedModels.set(childThreadId, item.model); - } - continue; - } + if (item.type === "collabAgentToolCall" && item.tool === "spawnAgent") continue; for (const update of await this.createHistoryUpdates(item, sessionState)) { if (this.protocolVersion === 2) { await this.sendReplayMessageStart(session, update, startedReplayMessages); diff --git a/src/__tests__/CodexACPAgent/data/subagent-model-requested.json b/src/__tests__/CodexACPAgent/data/subagent-model-activity.json similarity index 100% rename from src/__tests__/CodexACPAgent/data/subagent-model-requested.json rename to src/__tests__/CodexACPAgent/data/subagent-model-activity.json diff --git a/src/__tests__/CodexACPAgent/data/subagent-model-inherited.json b/src/__tests__/CodexACPAgent/data/subagent-model-inherited.json deleted file mode 100644 index 87bf96eb8..000000000 --- a/src/__tests__/CodexACPAgent/data/subagent-model-inherited.json +++ /dev/null @@ -1,35 +0,0 @@ -[ - { - "sessionId": "root", - "update": { - "sessionUpdate": "subagent_spawned", - "subagentSessionId": "child-1", - "name": "Reviewer", - "task": "Review the diff", - "capabilities": {} - } - }, - { - "sessionId": "child-1", - "update": { - "sessionUpdate": "config_option_update", - "configOptions": [ - { - "id": "model", - "name": "Model", - "description": "Model Codex uses for the subagent", - "category": "model", - "type": "select", - "currentValue": "gpt-5.5", - "options": [ - { - "value": "gpt-5.5", - "name": "5.5", - "description": "gpt-5.5 model" - } - ] - } - ] - } - } -] \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/load-session.test.ts b/src/__tests__/CodexACPAgent/load-session.test.ts index 65122c838..86e878253 100644 --- a/src/__tests__/CodexACPAgent/load-session.test.ts +++ b/src/__tests__/CodexACPAgent/load-session.test.ts @@ -455,7 +455,7 @@ describe("CodexACPAgent - loadSession", () => { ]); }); - it("replays the model of a child only when the history names it", async () => { + it("replays the model of a child only when its activity names it", async () => { const fixture = createCodexMockTestFixture(); const agent = fixture.getCodexAcpAgent(); const client = fixture.getCodexAcpClient(); @@ -529,12 +529,12 @@ describe("CodexACPAgent - loadSession", () => { }); const root = makeThread("root-history", [ turn("root-turn-1", [ + spawn("resolved", null), + started("resolved", model.id), + started("unknown", "custom-model"), + // A spawn request does not name the model: a role config can override it. spawn("requested", model.id), started("requested", null), - started("resolved", "custom-model"), - // Only the current model of the root could name the model of this child. - spawn("inherited", null), - started("inherited", null), ]), ]); appServer.threadResume = vi.fn().mockResolvedValue({ @@ -568,11 +568,11 @@ describe("CodexACPAgent - loadSession", () => { ? `${sessionId} subagent_spawned ${update.subagentSessionId}` : `${sessionId} model ${update.configOptions[0].currentValue} "${update.configOptions[0].options[0].name}"`); expect(updates).toEqual([ - "root-history subagent_spawned requested", - `requested model ${model.id} "${model.id}"`, "root-history subagent_spawned resolved", - `resolved model custom-model "custom-model"`, - "root-history subagent_spawned inherited", + `resolved model ${model.id} "${model.id}"`, + "root-history subagent_spawned unknown", + `unknown model custom-model "custom-model"`, + "root-history subagent_spawned requested", ]); }); diff --git a/src/__tests__/CodexACPAgent/subagent-model.test.ts b/src/__tests__/CodexACPAgent/subagent-model.test.ts index a5de6eef9..84a14765d 100644 --- a/src/__tests__/CodexACPAgent/subagent-model.test.ts +++ b/src/__tests__/CodexACPAgent/subagent-model.test.ts @@ -119,23 +119,6 @@ function settings(model: string): ServerNotification { return {method: "thread/settings/updated", params: {threadId: "child-1", threadSettings: {model} as never}}; } -function rootTurn(status: "completed" | "inProgress"): ServerNotification { - const turn = { - id: "root-turn", - items: [], - rootTurnId: null, - itemsView: "notLoaded" as const, - status, - error: null, - startedAt: null, - completedAt: null, - durationMs: null, - }; - return status === "completed" - ? {method: "turn/completed", params: {threadId: "root", turn}} - : {method: "turn/started", params: {threadId: "root", turn}}; -} - function threadStarted(threadId: string, model: string): ServerNotification { return {method: "thread/started", params: {thread: {id: threadId, model} as never}}; } @@ -155,24 +138,22 @@ describe('the model of a native subagent session', () => { expectConformingV2SessionUpdates(); }); - it('reports the requested model on the child session right after the announcement', async () => { + it('reports the resolved activity model on the child session right after the announcement', async () => { const {router, sent} = v1Router(); - await router.handle(spawn("gpt-5.2-codex")); - await router.handle(activity(null)); + await feed(router, spawn("gpt-5.5"), activity("gpt-5.2-codex")); expect(sent.map(({sessionId, update}) => `${sessionId} ${update["sessionUpdate"]}`)).toEqual([ "root subagent_spawned", "child-1 config_option_update", ]); - await expect(JSON.stringify(sent, null, 2)).toMatchFileSnapshot('data/subagent-model-requested.json'); + await expect(JSON.stringify(sent, null, 2)).toMatchFileSnapshot('data/subagent-model-activity.json'); }); it('uses the value and the name of the same model in the parent model option', async () => { const {router, sent} = v1Router(); - await router.handle(spawn("gpt-5.2-codex")); - await router.handle(activity(null)); + await feed(router, spawn(null), activity("gpt-5.2-codex")); const parentOption = createModelConfigOption([...MODELS.availableModels], MODELS.currentModel!); const parentEntry = ((parentOption as {options: Array<{value: string}>}).options) @@ -182,20 +163,21 @@ describe('the model of a native subagent session', () => { expect(childOption["options"]).toEqual([parentEntry]); }); - it('reports the model of the parent for a spawn that requests no model', async () => { + it.each([ + {requested: "gpt-5.2-codex"}, + {requested: null}, + ])('guesses no model from the spawn request or the parent ($requested)', async ({requested}) => { const {router, sent} = v1Router(); - await router.handle(spawn(null)); - await router.handle(activity(null)); + await feed(router, spawn(requested), activity(null)); - await expect(JSON.stringify(sent, null, 2)).toMatchFileSnapshot('data/subagent-model-inherited.json'); + expect(sent.map(({update}) => update["sessionUpdate"])).toEqual(["subagent_spawned"]); }); - it('prefers the model that Codex resolved for the child, and keeps an unknown model id as it is', async () => { + it('keeps an unknown model id as it is', async () => { const {router, sent} = v1Router(); - await router.handle(spawn("gpt-5.2-codex")); - await router.handle(activity("custom-model")); + await feed(router, spawn(null), activity("custom-model")); expect(modelOptionOf(sent[1]!.update)).toMatchObject({ currentValue: "custom-model", @@ -206,97 +188,64 @@ describe('the model of a native subagent session', () => { it('reports the model again on the new session of a reopened child', async () => { const {router, sent} = v1Router(); - await router.handle(spawn("gpt-5.2-codex")); - await router.handle(activity(null)); - await router.handle(childTurn("first-turn", "completed")); - await router.handle(childTurn("followup-turn", "inProgress")); + await feed( + router, + spawn(null), + activity("gpt-5.2-codex"), + childTurn("first-turn", "completed"), + childTurn("followup-turn", "inProgress"), + ); await expect(JSON.stringify(sent, null, 2)).toMatchFileSnapshot('data/subagent-model-reopen.json'); }); - it('reports a new snapshot when the settings of the child thread change its model', async () => { + it('reports the thread model on a reopened child that ended before its announcement', async () => { const {router, sent} = v1Router(); - await router.handle(spawn(null)); - await router.handle(activity(null)); - const settings = (model: string): ServerNotification => ({ - method: "thread/settings/updated", - params: {threadId: "child-1", threadSettings: {model} as never}, - }); - await router.handle(settings("gpt-5.5")); - await router.handle(settings("gpt-5.2-codex")); - - expect(sent.map(({sessionId, update}) => [sessionId, update["sessionUpdate"]])).toEqual([ - ["root", "subagent_spawned"], - ["child-1", "config_option_update"], - ["child-1", "config_option_update"], - ]); - expect(modelOptionOf(sent[2]!.update)["currentValue"]).toBe("gpt-5.2-codex"); - }); - - it('sends no configuration when the model of the child is not known', async () => { - const {router, sent} = v1Router({currentModel: null, availableModels: []}); - - await router.handle(spawn(null)); - await router.handle(activity(null)); - - expect(sent.map(({update}) => update["sessionUpdate"])).toEqual(["subagent_spawned"]); - }); - - it('renders the child configuration in the ACP v2 shape', async () => { - const {router, sent} = v2Router(); - - await router.handle(spawn("gpt-5.2-codex")); - await router.handle(activity(null)); - - await expect(JSON.stringify(sent, null, 2)).toMatchFileSnapshot('data/subagent-model-v2.json'); - }); - - it('keeps the model of the parent at the spawn when the root model changes before a reopen', async () => { - const models = {...MODELS}; - const {router, sent} = v1Router(models); - - await router.handle(spawn(null)); - // The child ends before Codex reports its activity, so it is not announced. - await router.handle(childTurn("first-turn", "completed")); - models.currentModel = "gpt-5.2-codex"; - await router.handle(childTurn("followup-turn", "inProgress")); + await feed( + router, + spawn(null), + threadStarted("child-1", "gpt-5.2-codex"), + // The child ends before Codex reports its activity, so it is not announced. + childTurn("first-turn", "completed"), + childTurn("followup-turn", "inProgress"), + ); - expect(modelReports(sent)).toEqual(['child-1:generation:2 gpt-5.5 "5.5"']); + expect(modelReports(sent)).toEqual(['child-1:generation:2 gpt-5.2-codex "5.2 Codex"']); }); - it('keeps the model of the parent at the spawn when the root model changes before the announcement', async () => { - const models = {...MODELS}; - const {router, sent} = v1Router(models); + it('reports a new snapshot when the settings of the child thread change its model', async () => { + const {router, sent} = v1Router(); - await router.handle(spawn(null)); - models.currentModel = "gpt-5.2-codex"; - await router.handle(activity(null)); + await feed(router, spawn(null), activity("gpt-5.5"), settings("gpt-5.5"), settings("gpt-5.2-codex")); - expect(modelReports(sent)).toEqual(['child-1 gpt-5.5 "5.5"']); + expect(modelReports(sent)).toEqual(['child-1 gpt-5.5 "5.5"', 'child-1 gpt-5.2-codex "5.2 Codex"']); }); - it('gives a nested child that requests no model the model of its parent child', async () => { + it('reports for a nested child only the model that Codex states for it', async () => { const {router, sent} = v1Router(); - await router.handle(spawn("gpt-5.2-codex")); - await router.handle(activity(null)); - await router.handle(spawn(null, "grandchild-1", "child-1")); - await router.handle(activity(null, "grandchild-1", "/root/reviewer/helper")); + await feed( + router, + spawn(null), + activity("gpt-5.2-codex"), + spawn(null, "grandchild-1", "child-1"), + activity(null, "grandchild-1", "/root/reviewer/helper"), + ); - expect(modelReports(sent)).toEqual([ - 'child-1 gpt-5.2-codex "5.2 Codex"', - 'grandchild-1 gpt-5.2-codex "5.2 Codex"', - ]); + expect(modelReports(sent)).toEqual(['child-1 gpt-5.2-codex "5.2 Codex"']); }); it('sends no configuration to a client that gets no session configuration', async () => { const {router, sent} = v1Router(null); - await router.handle(spawn("gpt-5.2-codex")); - await router.handle(threadStarted("child-1", "gpt-5.5")); - await router.handle(activity("custom-model")); - await router.handle(threadStarted("child-1", "gpt-5.2-codex")); + await feed( + router, + spawn("gpt-5.2-codex"), + threadStarted("child-1", "gpt-5.5"), + activity("custom-model"), + settings("gpt-5.2-codex"), + ); expect(sent.map(({update}) => update["sessionUpdate"])).toEqual(["subagent_spawned"]); }); @@ -304,9 +253,7 @@ describe('the model of a native subagent session', () => { it('sends nothing to a client without native subagent sessions', async () => { const {router, sent} = v1Router(MODELS, false); - await router.handle(spawn("gpt-5.2-codex")); - await router.handle(activity(null)); - await router.handle(threadStarted("child-1", "gpt-5.5")); + await feed(router, spawn("gpt-5.2-codex"), activity("gpt-5.2-codex"), threadStarted("child-1", "gpt-5.5")); expect(sent).toEqual([]); }); @@ -344,7 +291,7 @@ describe('the model of a native subagent session', () => { it('announces a pending child with the model of a settings change, and reports it once', async () => { const {router, sent} = v1Router(); - await feed(router, spawn("gpt-5.5"), settings("gpt-5.2-codex"), activity(null)); + await feed(router, spawn(null), settings("gpt-5.2-codex"), activity("gpt-5.5")); expect(modelReports(sent)).toEqual(['child-1 gpt-5.2-codex "5.2 Codex"']); }); @@ -357,24 +304,11 @@ describe('the model of a native subagent session', () => { expect(modelReports(sent)).toEqual(['child-1 gpt-5.2-codex "5.2 Codex"', 'child-1 gpt-5.5 "5.5"']); }); - it('inherits the root model of the running turn, not a model that the client set during the turn', async () => { - const models = {...MODELS}; - const {router, sent} = v1Router(models); - - await feed(router, rootTurn("inProgress")); - models.currentModel = "gpt-5.2-codex"; - await feed(router, spawn(null), activity(null), rootTurn("completed")); - await feed(router, rootTurn("inProgress"), spawn(null, "child-2"), activity(null, "child-2", "/root/second")); - - expect(modelReports(sent)).toEqual(['child-1 gpt-5.5 "5.5"', 'child-2 gpt-5.2-codex "5.2 Codex"']); - }); - it('sends no new snapshot when another spelling of the model gives the same option value', async () => { const {router, sent} = v1Router(); - await router.handle(spawn("codex-fast")); - await router.handle(activity(null)); - await router.handle(threadStarted("child-1", "gpt-5.2-codex-fast")); + await feed(router, spawn(null), activity("codex-fast"), threadStarted("child-1", "gpt-5.2-codex-fast"), + settings("gpt-5.2-codex-fast")); expect(modelReports(sent)).toEqual(['child-1 codex-fast "5.2 Codex Fast"']); }); @@ -382,8 +316,7 @@ describe('the model of a native subagent session', () => { it('keeps the full model id when the parent session has no model option', async () => { const {router, sent} = v1Router({currentModel: "gpt-5.5", availableModels: []}); - await router.handle(spawn(null)); - await router.handle(activity(null)); + await feed(router, spawn(null), activity("gpt-5.5")); expect(modelReports(sent)).toEqual(['child-1 gpt-5.5 "gpt-5.5"']); }); @@ -406,10 +339,17 @@ describe('the model of a native subagent session', () => { () => MODELS, ); - await router.handle(spawn("gpt-5.2-codex")); - await router.handle(activity(null)); + await feed(router, spawn(null), activity("gpt-5.2-codex")); expect(sent).toEqual(["subagent_spawned"]); expect(await router.waitForMaterializedSession("child-1")).toBe("child-1"); }); + + it('renders the child configuration in the ACP v2 shape', async () => { + const {router, sent} = v2Router(); + + await feed(router, spawn(null), activity("gpt-5.2-codex")); + + await expect(JSON.stringify(sent, null, 2)).toMatchFileSnapshot('data/subagent-model-v2.json'); + }); }); diff --git a/src/__tests__/CodexACPAgent/v2-resume-replay-subagents.test.ts b/src/__tests__/CodexACPAgent/v2-resume-replay-subagents.test.ts index b19b26873..9d6b42771 100644 --- a/src/__tests__/CodexACPAgent/v2-resume-replay-subagents.test.ts +++ b/src/__tests__/CodexACPAgent/v2-resume-replay-subagents.test.ts @@ -239,10 +239,10 @@ describe('ACP v2 resume replay: native subagent history (nativeSubagentSessions }); it.each([ - {requestedModel: "gpt-5", expected: [{sessionId: childId, value: "gpt-5", name: "5"}]}, - // Only the current model of the root could name the model of this child, so replay sends no model. - {requestedModel: null, expected: []}, - ])('replays the model of a child only when the history names it ($requestedModel)', async ({requestedModel, expected}) => { + {activityModel: "gpt-5", expected: [{sessionId: childId, value: "gpt-5", name: "5"}]}, + // The spawn request names a model, but a role config can override it, so replay sends no model. + {activityModel: null, expected: []}, + ])('replays the model of a child only when its activity names it ($activityModel)', async ({activityModel, expected}) => { const root: Thread["turns"] = [{ ...rootTurns[0]!, items: [ @@ -254,11 +254,12 @@ describe('ACP v2 resume replay: native subagent history (nativeSubagentSessions senderThreadId: rootId, receiverThreadIds: [childId], prompt: "Research", - model: requestedModel, + model: "gpt-5", reasoningEffort: null, agentsStates: {}, }, - ...rootTurns[0]!.items, + {...rootTurns[0]!.items[0]!, model: activityModel} as Thread["turns"][number]["items"][number], + ...rootTurns[0]!.items.slice(1), ], }]; const {connection, updates} = await connectV2Client({ diff --git a/src/__tests__/scenarios/client-profiles.test.ts b/src/__tests__/scenarios/client-profiles.test.ts index 7ca29c35b..cc23f94f5 100644 --- a/src/__tests__/scenarios/client-profiles.test.ts +++ b/src/__tests__/scenarios/client-profiles.test.ts @@ -421,7 +421,6 @@ describe("subagents", () => { it("air: gets the child session before the child output, and the child state on the parent", () => { expect(timeline("air", "native-subagent-session")).toEqual([ "session-1 subagent_spawned child-thread", - "child-thread config_option_update", "child-thread tool_call child-cmd", "child-thread tool_call_update child-cmd", "child-thread tool_call_update child-cmd", @@ -435,9 +434,7 @@ describe("subagents", () => { it("air: gets a nested child on its immediate parent", () => { expect(timeline("air", "nested-subagent-session")).toEqual([ "session-1 subagent_spawned child-thread", - "child-thread config_option_update", "child-thread subagent_spawned grandchild-thread", - "grandchild-thread config_option_update", "grandchild-thread tool_call grandchild-cmd", "grandchild-thread tool_call_update grandchild-cmd", "grandchild-thread agent_message_chunk", @@ -449,7 +446,6 @@ describe("subagents", () => { it("air: gets no child update after the child ends", () => { expect(timeline("air", "late-subagent-update")).toEqual([ "session-1 subagent_spawned child-thread", - "child-thread config_option_update", "child-thread tool_call child-cmd", "session-1 subagent_state_update child-thread", ]); diff --git a/src/__tests__/scenarios/data/air/collab-controls.jsonl b/src/__tests__/scenarios/data/air/collab-controls.jsonl index 72b8e84df..bb098c469 100644 --- a/src/__tests__/scenarios/data/air/collab-controls.jsonl +++ b/src/__tests__/scenarios/data/air/collab-controls.jsonl @@ -3,7 +3,6 @@ {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"capabilities":{},"name":"Weather","sessionUpdate":"subagent_spawned","subagentSessionId":"child-thread","task":"Find the weather in Paris."}}} -{"direction":"notify","method":"session/update","params":{"sessionId":"child-thread","update":{"configOptions":[{"category":"model","currentValue":"model-id","description":"Model Codex uses for the subagent","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"}],"sessionUpdate":"config_option_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"kind":"other","rawInput":{"agentsStates":{"child-thread":{"message":null,"status":"running"}},"model":null,"prompt":null,"reasoningEffort":null,"receiverThreadIds":["child-thread"],"senderThreadId":"session-1"},"sessionUpdate":"tool_call","status":"in_progress","title":"wait","toolCallId":"wait-1"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"wait-1"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"kind":"other","rawInput":{"agentsStates":{"child-thread":{"message":null,"status":"running"}},"model":null,"prompt":"Use Celsius.","reasoningEffort":null,"receiverThreadIds":["child-thread"],"senderThreadId":"session-1"},"sessionUpdate":"tool_call","status":"in_progress","title":"sendInput","toolCallId":"send-1"}}} diff --git a/src/__tests__/scenarios/data/air/late-subagent-update.jsonl b/src/__tests__/scenarios/data/air/late-subagent-update.jsonl index 06f3dae33..4f5f2c047 100644 --- a/src/__tests__/scenarios/data/air/late-subagent-update.jsonl +++ b/src/__tests__/scenarios/data/air/late-subagent-update.jsonl @@ -3,7 +3,6 @@ {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"capabilities":{},"name":"Weather","sessionUpdate":"subagent_spawned","subagentSessionId":"child-thread","task":"Find the weather in Paris."}}} -{"direction":"notify","method":"session/update","params":{"sessionId":"child-thread","update":{"configOptions":[{"category":"model","currentValue":"model-id","description":"Model Codex uses for the subagent","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"}],"sessionUpdate":"config_option_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"child-thread","update":{"_meta":{"terminal_info":{"cwd":"/workspace","terminal_id":"child-cmd"}},"content":[{"terminalId":"child-cmd","type":"terminal"}],"kind":"execute","rawInput":{"command":"curl wttr.in","cwd":"/workspace"},"sessionUpdate":"tool_call","status":"in_progress","title":"curl wttr.in","toolCallId":"child-cmd"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"subagent_state_update","state":"completed","subagentSessionId":"child-thread"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"session_info_update","title":"Go"}}} diff --git a/src/__tests__/scenarios/data/air/native-subagent-session.jsonl b/src/__tests__/scenarios/data/air/native-subagent-session.jsonl index 2d2133b2f..6a131504c 100644 --- a/src/__tests__/scenarios/data/air/native-subagent-session.jsonl +++ b/src/__tests__/scenarios/data/air/native-subagent-session.jsonl @@ -3,7 +3,6 @@ {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"capabilities":{},"name":"Weather","sessionUpdate":"subagent_spawned","subagentSessionId":"child-thread","task":"Find the weather in Paris."}}} -{"direction":"notify","method":"session/update","params":{"sessionId":"child-thread","update":{"configOptions":[{"category":"model","currentValue":"model-id","description":"Model Codex uses for the subagent","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"}],"sessionUpdate":"config_option_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"child-thread","update":{"_meta":{"terminal_info":{"cwd":"/workspace","terminal_id":"child-cmd"}},"content":[{"terminalId":"child-cmd","type":"terminal"}],"kind":"execute","rawInput":{"command":"curl wttr.in","cwd":"/workspace"},"sessionUpdate":"tool_call","status":"in_progress","title":"curl wttr.in","toolCallId":"child-cmd"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"child-thread","update":{"_meta":{"terminal_output_delta":{"data":"Sunny\n","terminal_id":"child-cmd"}},"sessionUpdate":"tool_call_update","toolCallId":"child-cmd"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"child-thread","update":{"_meta":{"terminal_exit":{"exit_code":0,"signal":null,"terminal_id":"child-cmd"}},"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"child-cmd"}}} diff --git a/src/__tests__/scenarios/data/air/nested-subagent-session.jsonl b/src/__tests__/scenarios/data/air/nested-subagent-session.jsonl index 58a8f77a8..dbef295c5 100644 --- a/src/__tests__/scenarios/data/air/nested-subagent-session.jsonl +++ b/src/__tests__/scenarios/data/air/nested-subagent-session.jsonl @@ -3,9 +3,7 @@ {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"capabilities":{},"name":"Weather","sessionUpdate":"subagent_spawned","subagentSessionId":"child-thread","task":"Find the weather in Paris."}}} -{"direction":"notify","method":"session/update","params":{"sessionId":"child-thread","update":{"configOptions":[{"category":"model","currentValue":"model-id","description":"Model Codex uses for the subagent","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"}],"sessionUpdate":"config_option_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"child-thread","update":{"capabilities":{},"name":"Lyon","sessionUpdate":"subagent_spawned","subagentSessionId":"grandchild-thread","task":"Check Lyon."}}} -{"direction":"notify","method":"session/update","params":{"sessionId":"grandchild-thread","update":{"configOptions":[{"category":"model","currentValue":"model-id","description":"Model Codex uses for the subagent","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"}],"sessionUpdate":"config_option_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"grandchild-thread","update":{"_meta":{"terminal_info":{"cwd":"/workspace","terminal_id":"grandchild-cmd"}},"content":[{"terminalId":"grandchild-cmd","type":"terminal"}],"kind":"execute","rawInput":{"command":"curl wttr.in/Lyon","cwd":"/workspace"},"sessionUpdate":"tool_call","status":"in_progress","title":"curl wttr.in/Lyon","toolCallId":"grandchild-cmd"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"grandchild-thread","update":{"_meta":{"terminal_exit":{"exit_code":0,"signal":null,"terminal_id":"grandchild-cmd"},"terminal_output_delta":{"data":"Rain\n","terminal_id":"grandchild-cmd"}},"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"grandchild-cmd"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"grandchild-thread","update":{"content":{"text":"Rain in Lyon","type":"text"},"messageId":"grandchild-msg","sessionUpdate":"agent_message_chunk"}}} diff --git a/src/__tests__/scenarios/data/air/subagent-activity.jsonl b/src/__tests__/scenarios/data/air/subagent-activity.jsonl index ec9bc3d38..b3b0baab8 100644 --- a/src/__tests__/scenarios/data/air/subagent-activity.jsonl +++ b/src/__tests__/scenarios/data/air/subagent-activity.jsonl @@ -3,7 +3,6 @@ {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"capabilities":{},"name":"Weather","sessionUpdate":"subagent_spawned","subagentSessionId":"child-thread","task":"Delegated task for Weather"}}} -{"direction":"notify","method":"session/update","params":{"sessionId":"child-thread","update":{"configOptions":[{"category":"model","currentValue":"model-id","description":"Model Codex uses for the subagent","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"}],"sessionUpdate":"config_option_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"child-thread","update":{"content":{"text":"Sunny","type":"text"},"messageId":"child-msg","sessionUpdate":"agent_message_chunk"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"subagent_state_update","state":"completed","subagentSessionId":"child-thread"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"session_info_update","title":"Go"}}} diff --git a/src/subagents/CodexSubagentEventRouter.ts b/src/subagents/CodexSubagentEventRouter.ts index 0288a4b33..9712b9705 100644 --- a/src/subagents/CodexSubagentEventRouter.ts +++ b/src/subagents/CodexSubagentEventRouter.ts @@ -39,10 +39,11 @@ type EndedSpawn = { }; /** - * Where the adapter learned the model of a child. A source of a higher rank wins: a settings change is a real - * change of the model, and at the creation the activity item names the resolved model. + * Where Codex stated the model of a child. The adapter reports only these models and never guesses one from the + * spawn request or the parent. A source of a higher rank wins: at the creation the activity item names the resolved + * model, and a settings change is a real change of the model. */ -const MODEL_SOURCE_RANK = {inherited: 0, requested: 1, threadStarted: 2, activity: 3, settings: 4} as const; +const MODEL_SOURCE_RANK = {threadStarted: 0, activity: 1, settings: 2} as const; type ChildModel = { model: string; @@ -57,7 +58,7 @@ function preferredModel(current: ChildModel | undefined, next: ChildModel | unde return MODEL_SOURCE_RANK[next.source] >= MODEL_SOURCE_RANK[current.source] ? next : current; } -/** The models of the root session, which a child that does not request a model inherits. */ +/** The models of the root session, which give the value and the name of a child model. */ export type SubagentModels = { /** The base model id of the root session, or `null` when it is not known. */ currentModel: string | null; @@ -83,8 +84,6 @@ export class CodexSubagentEventRouter { private readonly activeLegacyActivities = new Set(); /** Models that Codex reported for threads before their spawn. The root turn end clears them. */ private readonly earlyThreadModels = new Map(); - /** The root model when the current root turn started, or `undefined` outside a root turn. */ - private rootTurnModel: string | null | undefined = undefined; /** * @param onChildSessionEnded runs when a native child session ends, so that the owner can release @@ -114,20 +113,13 @@ export class CodexSubagentEventRouter { }); } if (notification.method === "turn/started") { - // The running turn keeps its model when the client changes the session model. - if (notification.params.threadId === this.rootSessionId) { - this.rootTurnModel = this.rootModels()?.currentModel ?? null; - } const childTurn = this.isKnownChild(notification.params.threadId); // A follow-up can start a new turn of an ended child with no collaboration item that reports it. if (childTurn) await this.reopen(notification.params.threadId); return childTurn; } if (notification.method === "turn/completed") { - if (notification.params.threadId === this.rootSessionId) { - this.rootTurnModel = undefined; - this.earlyThreadModels.clear(); - } + if (notification.params.threadId === this.rootSessionId) this.earlyThreadModels.clear(); const childTurn = this.isKnownChild(notification.params.threadId); const state = terminalStateFromTurn(notification.params.turn.status); if (!state) return childTurn; @@ -208,8 +200,7 @@ export class CodexSubagentEventRouter { representedSpawn = true; continue; } - // The model is settled at the spawn, so a later change of the parent model does not change it. - const model = preferredModel(this.spawnModel(item.model, parentThreadId), this.takeEarlyModel(childSessionId)); + const model = this.takeEarlyModel(childSessionId); this.pendingSpawns.set(childSessionId, { parentThreadId, parentSessionId, @@ -397,7 +388,7 @@ export class CodexSubagentEventRouter { const parentSessionId = pending?.parentSessionId ?? inferredParent.sessionId; const task = pending?.task ?? `Delegated task for ${name}`; const model = preferredModel( - pending ? pending.model : preferredModel(this.spawnModel(null, parentThreadId), this.takeEarlyModel(childSessionId)), + pending ? pending.model : this.takeEarlyModel(childSessionId), resolvedModel ? {model: resolvedModel, source: "activity"} : undefined, ); await this.session.update({ @@ -540,21 +531,6 @@ export class CodexSubagentEventRouter { return model; } - /** The model that a spawn requested, else the model of its parent. */ - private spawnModel(requested: string | null, parentThreadId: string): ChildModel | undefined { - if (requested) return {model: requested, source: "requested"}; - const inherited = this.inheritedModel(parentThreadId); - return inherited ? {model: inherited, source: "inherited"} : undefined; - } - - /** A child that requests no model runs with the model of its parent. */ - private inheritedModel(parentThreadId: string): string | undefined { - const parent = this.children.get(parentThreadId); - if (parent) return parent.model?.model; - const rootModel = this.rootTurnModel !== undefined ? this.rootTurnModel : this.rootModels()?.currentModel; - return rootModel ?? undefined; - } - /** The value of the model option for a model, which can differ from the model string that Codex reports. */ private modelValue(model: string): string { const models = this.rootModels(); From 392e4bfc14f04ea6b0d9519627f3536cd53a3b99 Mon Sep 17 00:00:00 2001 From: Nikita Ashikhmin Date: Sun, 25 Oct 2026 21:43:53 -0700 Subject: [PATCH 9/9] fix: keep subagent model reports stable and scoped to the root - The router reads a settings change of a pending child once. The buffered copy that comes back after the announcement is not read again, so two changes give one update with the last model. - An early thread/started model is kept only when the thread names this root, or a child of it, as its parent, or while a spawn of this root is in flight. The root turn end and the end of the outstanding children clear these models. - A model report checks the child just before it sends. A child that ended, or that a reopen gave another session, gets nothing. - The live session and the replay share one function that sends the model update and logs its failure. --- src/CodexAcpServer.ts | 24 ++-- .../CodexACPAgent/load-session.test.ts | 17 ++- .../CodexACPAgent/subagent-model.test.ts | 114 +++++++++++++++++- src/subagents/CodexSubagentEventRouter.ts | 103 +++++++++++----- 4 files changed, 205 insertions(+), 53 deletions(-) diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index a8e3e2bcf..3f7f160bf 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -91,7 +91,6 @@ import { import type {ModeKind} from "./app-server/ModeKind"; import { createModelConfigOption, - createSubagentModelConfigOption, createReasoningEffortConfigOption, findSupportedEffort, formatModelDisplayName, @@ -171,7 +170,11 @@ import { clientSupportsSubagents, type SubagentAwareSessionCapabilities, } from "./subagents/AcpSubagents"; -import {CodexSubagentEventRouter, type SubagentModels} from "./subagents/CodexSubagentEventRouter"; +import { + CodexSubagentEventRouter, + reportSubagentModel, + type SubagentModels, +} from "./subagents/CodexSubagentEventRouter"; import {nameFromAgentPath} from "./subagents/CodexAgentPath"; import {listCodexHooks, trustCodexHooks, type CodexHookIdentity} from "./CodexHookTrust"; import type {HooksListEntry} from "./app-server/v2/HooksListEntry"; @@ -3423,19 +3426,8 @@ export class CodexAcpServer { const announced = new Map(); // Sends the model of a replayed child after its announcement, as the live session does. Replay sends only // the resolved model that the activity items name for the child, so an older record gets no model option. - const reportModel = async (childSessionId: string, model: string | undefined) => { - const models = this.subagentModels(sessionState); - if (!model || !models) return; - try { - await session.update({ - sessionUpdate: "config_option_update", - configOptions: [createSubagentModelConfigOption(models.availableModels, models.currentModel, model)], - }, childSessionId); - } - catch (error) { - logger.error(`Failed to report the model of subagent session ${childSessionId}`, error); - } - }; + const reportModel = (childSessionId: string, model: string | undefined) => + reportSubagentModel(session, childSessionId, this.subagentModels(sessionState), model); // v2 only (this path also serves v1's native replay, unchanged there). const startedReplayMessages = new Set(); for await (const items of itemPages) { @@ -3515,7 +3507,7 @@ export class CodexAcpServer { const child = announced.get(item.agentThreadId); if (!child) { const name = nameFromAgentPath(item.agentPath, `Agent ${item.agentThreadId.slice(-8)}`); - const model = item.model ?? undefined; + const model = item.model || undefined; await session.update({ sessionUpdate: "subagent_spawned", subagentSessionId: item.agentThreadId, diff --git a/src/__tests__/CodexACPAgent/load-session.test.ts b/src/__tests__/CodexACPAgent/load-session.test.ts index 86e878253..71e7cca2d 100644 --- a/src/__tests__/CodexACPAgent/load-session.test.ts +++ b/src/__tests__/CodexACPAgent/load-session.test.ts @@ -518,12 +518,16 @@ describe("CodexACPAgent - loadSession", () => { reasoningEffort: null, agentsStates: {}, }); - const started = (childThreadId: string, resolvedModel: string | null) => ({ + const started = ( + childThreadId: string, + resolvedModel: string | null, + kind: "started" | "interacted" | "completed" = "started", + ) => ({ type: "subAgentActivity" as const, model: resolvedModel, reasoningEffort: null, - id: `started-${childThreadId}`, - kind: "started" as const, + id: `${kind}-${childThreadId}`, + kind, agentThreadId: childThreadId, agentPath: `/root/${childThreadId}`, }); @@ -535,7 +539,10 @@ describe("CodexACPAgent - loadSession", () => { // A spawn request does not name the model: a role config can override it. spawn("requested", model.id), started("requested", null), + started("resolved", null, "completed"), ]), + // Codex names the model only at the creation, so the follow-up generation reuses it. + turn("root-turn-2", [started("resolved", null, "interacted")]), ]); appServer.threadResume = vi.fn().mockResolvedValue({ thread: root, @@ -548,7 +555,7 @@ describe("CodexACPAgent - loadSession", () => { }); appServer.threadReadWithHistory = vi.fn().mockResolvedValue({thread: root}); appServer.threadRead = vi.fn().mockImplementation(({threadId}: {threadId: string}) => - Promise.resolve({thread: makeThread(threadId, [turn(`${threadId}-turn`, [])])})); + Promise.resolve({thread: makeThread(threadId, [turn(`${threadId}-turn-1`, []), turn(`${threadId}-turn-2`, [])])})); await agent.initialize({ protocolVersion: 1, @@ -573,6 +580,8 @@ describe("CodexACPAgent - loadSession", () => { "root-history subagent_spawned unknown", `unknown model custom-model "custom-model"`, "root-history subagent_spawned requested", + "root-history subagent_spawned resolved:generation:2", + `resolved:generation:2 model ${model.id} "${model.id}"`, ]); }); diff --git a/src/__tests__/CodexACPAgent/subagent-model.test.ts b/src/__tests__/CodexACPAgent/subagent-model.test.ts index 84a14765d..c2bd5f534 100644 --- a/src/__tests__/CodexACPAgent/subagent-model.test.ts +++ b/src/__tests__/CodexACPAgent/subagent-model.test.ts @@ -115,12 +115,32 @@ async function feed(router: CodexSubagentEventRouter, ...notifications: ServerNo } } -function settings(model: string): ServerNotification { - return {method: "thread/settings/updated", params: {threadId: "child-1", threadSettings: {model} as never}}; +function settings(model: string, child = "child-1"): ServerNotification { + return {method: "thread/settings/updated", params: {threadId: child, threadSettings: {model} as never}}; } -function threadStarted(threadId: string, model: string): ServerNotification { - return {method: "thread/started", params: {thread: {id: threadId, model} as never}}; +function threadStarted(threadId: string, model: string, parentThreadId: string | null = null): ServerNotification { + return {method: "thread/started", params: {thread: {id: threadId, model, parentThreadId} as never}}; +} + +function rootTurnCompleted(): ServerNotification { + return { + method: "turn/completed", + params: { + threadId: "root", + turn: { + id: "root-turn", + items: [], + rootTurnId: null, + itemsView: "notLoaded", + status: "completed", + error: null, + startedAt: null, + completedAt: null, + durationMs: null, + }, + }, + }; } /** The model updates as `session value "name"`. */ @@ -345,6 +365,92 @@ describe('the model of a native subagent session', () => { expect(await router.waitForMaterializedSession("child-1")).toBe("child-1"); }); + it('announces a pending child with the last of two buffered settings changes, and reports it once', async () => { + const {router, sent} = v1Router(); + + await feed(router, spawn(null), settings("gpt-5.2-codex"), settings("codex-fast"), activity("gpt-5.5")); + + expect(modelReports(sent)).toEqual(['child-1 codex-fast "5.2 Codex Fast"']); + }); + + it('keeps no early model of a thread that is not of this root', async () => { + const {router, sent} = v1Router(); + + // No spawn of this root is in flight, and the thread names another parent. + await feed( + router, + threadStarted("child-1", "gpt-5.2-codex", "other-root"), + spawn(null, "child-1", "root", ["child-1"], "item/completed"), + activity(null), + ); + + expect(modelReports(sent)).toEqual([]); + }); + + it('takes an early model of a thread that names this root as its parent', async () => { + const {router, sent} = v1Router(); + + await feed( + router, + threadStarted("child-1", "gpt-5.2-codex", "root"), + spawn(null, "child-1", "root", ["child-1"], "item/completed"), + activity(null), + ); + + expect(modelReports(sent)).toEqual(['child-1 gpt-5.2-codex "5.2 Codex"']); + }); + + it('drops the early models when the root turn ends', async () => { + const {router, sent} = v1Router(); + + await feed( + router, + spawn(null, "child-1", "root", []), + threadStarted("child-1", "gpt-5.2-codex"), + rootTurnCompleted(), + spawn(null, "child-1", "root", ["child-1"], "item/completed"), + activity(null), + ); + + expect(modelReports(sent)).toEqual([]); + }); + + it.each([ + {announcement: "first"}, + {announcement: "reopen"}, + ])('sends no model to a child that ends while the router announces it ($announcement)', async ({announcement}) => { + const sent: string[] = []; + let spawned = 0; + let router: CodexSubagentEventRouter | undefined; + const endOn = announcement === "first" ? 1 : 2; + const connection = { + notify: vi.fn(async (_method: string, params: unknown) => { + const update = (params as SentUpdate).update; + sent.push(String(update["sessionUpdate"])); + // A prompt wait timeout can end the children in this gap. + if (update["sessionUpdate"] === "subagent_spawned" && ++spawned === endOn) { + await router!.finishOutstanding("failed"); + } + }), + request: vi.fn(), + } as unknown as AcpClientConnection; + router = new CodexSubagentEventRouter( + "root", + true, + new ACPSessionConnection(connection, "root"), + () => {}, + () => MODELS, + ); + + await feed(router, spawn(null), activity("gpt-5.2-codex")); + if (announcement === "reopen") { + sent.length = 0; + await feed(router, childTurn("first-turn", "completed"), childTurn("followup-turn", "inProgress")); + } + + expect(sent).not.toContain("config_option_update"); + }); + it('renders the child configuration in the ACP v2 shape', async () => { const {router, sent} = v2Router(); diff --git a/src/subagents/CodexSubagentEventRouter.ts b/src/subagents/CodexSubagentEventRouter.ts index 9712b9705..3e1a7d982 100644 --- a/src/subagents/CodexSubagentEventRouter.ts +++ b/src/subagents/CodexSubagentEventRouter.ts @@ -65,6 +65,29 @@ export type SubagentModels = { availableModels: ReadonlyArray; }; +/** + * Sends the model option of a child session. The client may not change it, so the option offers only the current + * model. A child whose model is not known gets no configuration. The report is optional, so its failure is logged + * and does not stop the child lifecycle or a replay. + */ +export async function reportSubagentModel( + session: ACPSessionConnection, + childSessionId: string, + models: SubagentModels | null, + model: string | undefined, +): Promise { + if (!model || !models) return; + try { + await session.update({ + sessionUpdate: "config_option_update", + configOptions: [createSubagentModelConfigOption(models.availableModels, models.currentModel, model)], + }, childSessionId); + } + catch (error) { + logger.error(`Failed to report the model of subagent session ${childSessionId}`, error); + } +} + export type ClosingChildSession = { threadId: string; sessionId: string; @@ -82,8 +105,18 @@ export class CodexSubagentEventRouter { private readonly materializationWaiters = new Map void>>(); private readonly replayQueue: ServerNotification[] = []; private readonly activeLegacyActivities = new Set(); - /** Models that Codex reported for threads before their spawn. The root turn end clears them. */ + /** + * Models that Codex reported for threads of this root before their spawn named them. The root turn end and the + * end of the outstanding children clear them. The router goes away with its session. + */ private readonly earlyThreadModels = new Map(); + /** The `spawnAgent` items of this root that started and did not complete yet. */ + private readonly spawnsInFlight = new Set(); + /** + * The params of settings changes that the router read while it buffered them for a pending child. The buffer + * stores a copy of the notification with the same params. + */ + private readonly appliedSettings = new WeakSet(); /** * @param onChildSessionEnded runs when a native child session ends, so that the owner can release @@ -102,15 +135,25 @@ export class CodexSubagentEventRouter { async handle(notification: ServerNotification): Promise { if (notification.method === "thread/started") { const thread = notification.params.thread; - if (thread.model) await this.applyThreadModel(thread.id, {model: thread.model, source: "threadStarted"}); + // Every session gets the thread starts of the app-server, so a thread that is not known yet must name + // this root as its parent, or start while a spawn of this root is in flight. + const ofThisRoot = thread.parentThreadId === this.rootSessionId + || (thread.parentThreadId !== null && this.children.has(thread.parentThreadId)) + || this.spawnsInFlight.size > 0; + if (thread.model) { + await this.applyThreadModel(thread.id, {model: thread.model, source: "threadStarted"}, ofThisRoot); + } return false; } - if (notification.method === "thread/settings/updated") { - // Read before the buffering below, so that a pending child announces with this model. - await this.applyThreadModel(notification.params.threadId, { + // Read a settings change before the buffering below, so that a pending child announces with its model. + // The buffered copy comes back after the announcement, and the router does not read it again. + if (notification.method === "thread/settings/updated" && !this.appliedSettings.delete(notification.params)) { + const threadId = notification.params.threadId; + await this.applyThreadModel(threadId, { model: notification.params.threadSettings.model, source: "settings", - }); + }, this.spawnsInFlight.size > 0); + if (this.pendingSpawns.has(threadId)) this.appliedSettings.add(notification.params); } if (notification.method === "turn/started") { const childTurn = this.isKnownChild(notification.params.threadId); @@ -119,7 +162,10 @@ export class CodexSubagentEventRouter { return childTurn; } if (notification.method === "turn/completed") { - if (notification.params.threadId === this.rootSessionId) this.earlyThreadModels.clear(); + if (notification.params.threadId === this.rootSessionId) { + this.earlyThreadModels.clear(); + this.spawnsInFlight.clear(); + } const childTurn = this.isKnownChild(notification.params.threadId); const state = terminalStateFromTurn(notification.params.turn.status); if (!state) return childTurn; @@ -156,7 +202,7 @@ export class CodexSubagentEventRouter { // shape as children. It is the parent conversation, not a subagent. if (isRootAgentPath(item.agentPath)) return true; if (this.terminalPendingSpawns.has(item.agentThreadId)) { - if (item.model) await this.applyThreadModel(item.agentThreadId, {model: item.model, source: "activity"}); + if (item.model) await this.applyThreadModel(item.agentThreadId, {model: item.model, source: "activity"}, false); return true; } let hasNativeRepresentation = this.children.has(item.agentThreadId); @@ -182,6 +228,8 @@ export class CodexSubagentEventRouter { let representedSpawn = false; if (item.tool === "spawnAgent") { + if (notification.method === "item/started") this.spawnsInFlight.add(item.id); + else this.spawnsInFlight.delete(item.id); const parent = this.children.get(item.senderThreadId); const parentThreadId = parent ? item.senderThreadId : this.rootSessionId; const parentSessionId = parent?.sessionId ?? this.rootSessionId; @@ -357,6 +405,8 @@ export class CodexSubagentEventRouter { } async finishOutstanding(state: SubagentState): Promise { + this.earlyThreadModels.clear(); + this.spawnsInFlight.clear(); for (const childSessionId of [...this.pendingSpawns.keys()]) { this.finishPending(childSessionId); } @@ -412,7 +462,7 @@ export class CodexSubagentEventRouter { // A loop, not push(...): a spread of a large buffer exceeds the argument limit and throws. for (const notification of pending?.buffered.take() ?? []) this.replayQueue.push(notification); this.resolveMaterialization(childSessionId, childSessionId); - await this.reportModel(childSessionId, model?.model); + await this.reportModel(childSessionId, childSessionId); } private finishPending(childSessionId: string): void { @@ -474,7 +524,7 @@ export class CodexSubagentEventRouter { }, parentSessionId); this.children.set(childThreadId, reopened); this.terminalPendingSpawns.delete(childThreadId); - await this.reportModel(reopened.sessionId, reopened.model?.model); + await this.reportModel(childThreadId, reopened.sessionId); return; } if (!child.terminalState) return; @@ -501,11 +551,11 @@ export class CodexSubagentEventRouter { child.terminalState = previousState; throw error; } - await this.reportModel(child.sessionId, child.model?.model); + await this.reportModel(childThreadId, child.sessionId); } /** Takes a model that Codex reports for a child thread, when its source does not rank below the known one. */ - private async applyThreadModel(threadId: string, model: ChildModel): Promise { + private async applyThreadModel(threadId: string, model: ChildModel, mayBeEarly: boolean): Promise { if (!this.supported || threadId === this.rootSessionId) return; const pending = this.pendingSpawns.get(threadId) ?? this.terminalPendingSpawns.get(threadId); if (pending) { @@ -515,14 +565,16 @@ export class CodexSubagentEventRouter { const child = this.children.get(threadId); if (!child) { // A spawn can name its child only when it completes, after the child thread started. - this.earlyThreadModels.set(threadId, preferredModel(this.earlyThreadModels.get(threadId), model)); + if (mayBeEarly) { + this.earlyThreadModels.set(threadId, preferredModel(this.earlyThreadModels.get(threadId), model)); + } return; } const previous = child.model; child.model = preferredModel(previous, model); if (child.model === previous || child.terminalState !== undefined) return; if (previous && this.modelValue(previous.model) === this.modelValue(model.model)) return; - await this.reportModel(child.sessionId, model.model); + await this.reportModel(threadId, child.sessionId); } private takeEarlyModel(threadId: string): ChildModel | undefined { @@ -539,22 +591,15 @@ export class CodexSubagentEventRouter { } /** - * Sends the configuration of a child session after its announcement. The client may not change it, so the - * model option offers only the current model. A child whose model is not known gets no configuration. - * The report is optional, so its failure does not stop the child lifecycle. + * Sends the current model of a child session after its announcement. A child that ended, or that a reopen gave + * another session, gets nothing: an end can come while the router waits for the client. */ - private async reportModel(childSessionId: string, model: string | undefined): Promise { - const models = this.rootModels(); - if (!model || !models) return; - try { - await this.session.update({ - sessionUpdate: "config_option_update", - configOptions: [createSubagentModelConfigOption(models.availableModels, models.currentModel, model)], - }, childSessionId); - } - catch (error) { - logger.error(`Failed to report the model of subagent session ${childSessionId}`, error); - } + private async reportModel(childThreadId: string, childSessionId: string): Promise { + const child = this.children.get(childThreadId); + if (!child || child.terminalState !== undefined || child.sessionId !== childSessionId) return; + // A pending child can end while the router waits for the client to take its announcement. + if (this.terminalPendingSpawns.has(childThreadId)) return; + await reportSubagentModel(this.session, childSessionId, this.rootModels(), child.model?.model); } private resolveMaterialization(childThreadId: string, sessionId: string | null): void {