diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index 27d739e1..3f7f160b 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -170,7 +170,11 @@ import { clientSupportsSubagents, type SubagentAwareSessionCapabilities, } from "./subagents/AcpSubagents"; -import {CodexSubagentEventRouter} 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"; @@ -1213,6 +1217,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 +3127,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 | null { + if (!this.isSessionConfigEnabled()) return null; + let currentModel: string | null = null; + try { + currentModel = ModelId.fromString(sessionState.currentModelId).model; + } + catch { + // A session without a known model gives no current entry to match. + } + 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 +3289,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(), @@ -3404,7 +3423,11 @@ export class CodexAcpServer { isOpen: () => boolean, ): Promise { const session = new ACPSessionConnection(this.connection, sessionId); - const announced = new Map(); + 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 = (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) { @@ -3436,6 +3459,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; await session.update({ sessionUpdate: "subagent_spawned", subagentSessionId: childSessionId, @@ -3443,7 +3467,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 { @@ -3477,6 +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; await session.update({ sessionUpdate: "subagent_spawned", subagentSessionId: item.agentThreadId, @@ -3488,7 +3519,9 @@ export class CodexAcpServer { generation: 1, sessionId: item.agentThreadId, terminal: false, + ...(model ? {model} : {}), }); + await reportModel(item.agentThreadId, model); continue; } if (child.terminal) continue; diff --git a/src/ModelConfigOption.ts b/src/ModelConfigOption.ts index f05efe0d..429800d3 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,33 @@ 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 { + // 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 + ?? 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/agent-file-change-report.test.ts b/src/__tests__/CodexACPAgent/agent-file-change-report.test.ts index ed81fe36..55477ce7 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 23a02501..915fbe97 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 fac54d14..ed552c59 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/data/subagent-model-activity.json b/src/__tests__/CodexACPAgent/data/subagent-model-activity.json new file mode 100644 index 00000000..ad1a0441 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/subagent-model-activity.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-reopen.json b/src/__tests__/CodexACPAgent/data/subagent-model-reopen.json new file mode 100644 index 00000000..1c55256f --- /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-v2.json b/src/__tests__/CodexACPAgent/data/subagent-model-v2.json new file mode 100644 index 00000000..242f1029 --- /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/load-session.test.ts b/src/__tests__/CodexACPAgent/load-session.test.ts index cb4aaf8f..71e7cca2 100644 --- a/src/__tests__/CodexACPAgent/load-session.test.ts +++ b/src/__tests__/CodexACPAgent/load-session.test.ts @@ -455,6 +455,136 @@ describe("CodexACPAgent - loadSession", () => { ]); }); + 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(); + 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, + kind: "started" | "interacted" | "completed" = "started", + ) => ({ + type: "subAgentActivity" as const, + model: resolvedModel, + reasoningEffort: null, + id: `${kind}-${childThreadId}`, + kind, + agentThreadId: childThreadId, + agentPath: `/root/${childThreadId}`, + }); + 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", 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, + 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-1`, []), turn(`${threadId}-turn-2`, [])])})); + + 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} "${update.configOptions[0].options[0].name}"`); + expect(updates).toEqual([ + "root-history subagent_spawned resolved", + `resolved model ${model.id} "${model.id}"`, + "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}"`, + ]); + }); + it("should replay history during loadSession", async () => { const fixture = createCodexMockTestFixture(); const codexAcpAgent = fixture.getCodexAcpAgent(); diff --git a/src/__tests__/CodexACPAgent/session-compaction.test.ts b/src/__tests__/CodexACPAgent/session-compaction.test.ts index 6dc6d309..0064540e 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 2b353bd1..3981af17 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 fcd1c650..53456e90 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/subagent-model.test.ts b/src/__tests__/CodexACPAgent/subagent-model.test.ts new file mode 100644 index 00000000..c2bd5f53 --- /dev/null +++ b/src/__tests__/CodexACPAgent/subagent-model.test.ts @@ -0,0 +1,461 @@ +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}), + createTestModel({id: "codex-fast", model: "gpt-5.2-codex-fast", displayName: "gpt-5.2-codex-fast", isDefault: false}), + ], +}; + +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) => { + sent.push(params as SentUpdate); + }), + request: vi.fn(), + } as unknown as AcpClientConnection; + const session = new ACPSessionConnection(connection, "root"); + return {router: new CodexSubagentEventRouter("root", supported, 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, + 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: method === "item/started" ? "inProgress" : "completed", + senderThreadId: sender, + receiverThreadIds: receivers, + prompt: "Review the diff", + model, + reasoningEffort: null, + agentsStates: Object.fromEntries(receivers.map(id => [id, {status: "running" as const, message: null}])), + }; + 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 { + const item: ThreadItem = { + type: "subAgentActivity", + model, + reasoningEffort: null, + id: `activity-${child}`, + kind: "started", + agentThreadId: child, + agentPath: path, + }; + 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]!; +} + +/** 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, child = "child-1"): ServerNotification { + return {method: "thread/settings/updated", params: {threadId: child, threadSettings: {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"`. */ +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(); + }); + + it('reports the resolved activity model on the child session right after the announcement', async () => { + const {router, sent} = v1Router(); + + 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-activity.json'); + }); + + it('uses the value and the name of the same model in the parent model option', async () => { + const {router, sent} = v1Router(); + + 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) + .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.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 feed(router, spawn(requested), activity(null)); + + expect(sent.map(({update}) => update["sessionUpdate"])).toEqual(["subagent_spawned"]); + }); + + it('keeps an unknown model id as it is', async () => { + const {router, sent} = v1Router(); + + await feed(router, spawn(null), 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 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 the thread model on a reopened child that ended before its announcement', async () => { + const {router, sent} = v1Router(); + + 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.2-codex "5.2 Codex"']); + }); + + it('reports a new snapshot when the settings of the child thread change its model', async () => { + const {router, sent} = v1Router(); + + 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"', 'child-1 gpt-5.2-codex "5.2 Codex"']); + }); + + it('reports for a nested child only the model that Codex states for it', async () => { + const {router, sent} = v1Router(); + + 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"']); + }); + + it('sends no configuration to a client that gets no session configuration', async () => { + const {router, sent} = v1Router(null); + + 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"]); + }); + + it('sends nothing to a client without native subagent sessions', async () => { + const {router, sent} = v1Router(MODELS, false); + + await feed(router, spawn("gpt-5.2-codex"), activity("gpt-5.2-codex"), threadStarted("child-1", "gpt-5.5")); + + expect(sent).toEqual([]); + }); + + it('takes the model from thread/started when the activity names none', async () => { + const {router, sent} = v1Router(); + + 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"']); + }); + + 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(null), settings("gpt-5.2-codex"), activity("gpt-5.5")); + + 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('sends no new snapshot when another spelling of the model gives the same option value', async () => { + const {router, sent} = v1Router(); + + 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"']); + }); + + 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 feed(router, spawn(null), activity("gpt-5.5")); + + 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 feed(router, spawn(null), activity("gpt-5.2-codex")); + + expect(sent).toEqual(["subagent_spawned"]); + 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(); + + 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/token-usage-events.test.ts b/src/__tests__/CodexACPAgent/token-usage-events.test.ts index 1b0a5064..1683526b 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__/CodexACPAgent/v2-resume-replay-subagents.test.ts b/src/__tests__/CodexACPAgent/v2-resume-replay-subagents.test.ts index f8d148b4..9d6b4277 100644 --- a/src/__tests__/CodexACPAgent/v2-resume-replay-subagents.test.ts +++ b/src/__tests__/CodexACPAgent/v2-resume-replay-subagents.test.ts @@ -238,6 +238,51 @@ describe('ACP v2 resume replay: native subagent history (nativeSubagentSessions expect(agentMessages).toContain("Investigating the flaky test."); }); + it.each([ + {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: [ + { + type: "collabAgentToolCall", + id: "spawn-1", + tool: "spawnAgent", + status: "completed", + senderThreadId: rootId, + receiverThreadIds: [childId], + prompt: "Research", + model: "gpt-5", + reasoningEffort: null, + agentsStates: {}, + }, + {...rootTurns[0]!.items[0]!, model: activityModel} as Thread["turns"][number]["items"][number], + ...rootTurns[0]!.items.slice(1), + ], + }]; + 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, diff --git a/src/__tests__/acp-test-utils.ts b/src/__tests__/acp-test-utils.ts index 4608b591..fba992e7 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 5bb3ebfb..3e1a7d98 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?: ChildModel; generation: number; terminalState?: SubagentState; }; @@ -24,6 +27,7 @@ type PendingSubagent = { parentThreadId: string; parentSessionId: string; task: string; + model?: ChildModel; buffered: PendingNotificationBuffer; }; @@ -31,8 +35,59 @@ type PendingSubagent = { type EndedSpawn = { parentThreadId: string; task: string; + model?: ChildModel; }; +/** + * 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 = {threadStarted: 0, activity: 1, settings: 2} 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 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; + 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; @@ -50,19 +105,56 @@ export class CodexSubagentEventRouter { private readonly materializationWaiters = new Map void>>(); private readonly replayQueue: ServerNotification[] = []; private readonly activeLegacyActivities = new Set(); + /** + * 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 * the state that it keeps per child session. + * @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 | null, ) {} async handle(notification: ServerNotification): Promise { + if (notification.method === "thread/started") { + const thread = notification.params.thread; + // 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; + } + // 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); // A follow-up can start a new turn of an ended child with no collaboration item that reports it. @@ -70,6 +162,10 @@ export class CodexSubagentEventRouter { return childTurn; } if (notification.method === "turn/completed") { + 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; @@ -105,10 +201,13 @@ 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"}, false); + 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") { @@ -129,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; @@ -147,10 +248,12 @@ export class CodexSubagentEventRouter { representedSpawn = true; continue; } + const model = this.takeEarlyModel(childSessionId); this.pendingSpawns.set(childSessionId, { parentThreadId, parentSessionId, task: item.prompt?.trim() || "Delegated task", + ...(model ? {model} : {}), buffered: new PendingNotificationBuffer(childSessionId), }); representedSpawn = true; @@ -302,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); } @@ -324,7 +429,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 +437,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 = preferredModel( + pending ? pending.model : this.takeEarlyModel(childSessionId), + resolvedModel ? {model: resolvedModel, source: "activity"} : undefined, + ); await this.session.update({ sessionUpdate: "subagent_spawned", subagentSessionId: childSessionId, @@ -346,12 +455,14 @@ export class CodexSubagentEventRouter { name, task, path: normalizeAgentPath(path), + ...(model ? {model} : {}), generation: 1, }); this.pendingSpawns.delete(childSessionId); // 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, childSessionId); } private finishPending(childSessionId: string): void { @@ -360,7 +471,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(); @@ -397,6 +512,7 @@ export class CodexSubagentEventRouter { sessionId: `${childThreadId}:generation:2`, name: fallbackName(childThreadId), task: pending.task, + ...(pending.model ? {model: pending.model} : {}), generation: 2, }; await this.session.update({ @@ -408,6 +524,7 @@ export class CodexSubagentEventRouter { }, parentSessionId); this.children.set(childThreadId, reopened); this.terminalPendingSpawns.delete(childThreadId); + await this.reportModel(childThreadId, reopened.sessionId); return; } if (!child.terminalState) return; @@ -434,6 +551,55 @@ export class CodexSubagentEventRouter { child.terminalState = previousState; throw error; } + 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, mayBeEarly: boolean): Promise { + if (!this.supported || threadId === this.rootSessionId) return; + const pending = this.pendingSpawns.get(threadId) ?? this.terminalPendingSpawns.get(threadId); + if (pending) { + pending.model = preferredModel(pending.model, model); + return; + } + const child = this.children.get(threadId); + if (!child) { + // A spawn can name its child only when it completes, after the child thread started. + 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(threadId, child.sessionId); + } + + private takeEarlyModel(threadId: string): ChildModel | undefined { + const model = this.earlyThreadModels.get(threadId); + this.earlyThreadModels.delete(threadId); + return model; + } + + /** 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 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(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 {