Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 36 additions & 3 deletions src/CodexAcpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -3404,7 +3423,11 @@ export class CodexAcpServer {
isOpen: () => boolean,
): Promise<void> {
const session = new ACPSessionConnection(this.connection, sessionId);
const announced = new Map<string, {generation: number; sessionId: string; terminal: boolean}>();
const announced = new Map<string, {generation: number; sessionId: string; terminal: boolean; model?: string}>();
// 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<string>();
for await (const items of itemPages) {
Expand Down Expand Up @@ -3436,14 +3459,21 @@ 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,
name,
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<string>();
try {
Expand Down Expand Up @@ -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,
Expand All @@ -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;
Expand Down
52 changes: 45 additions & 7 deletions src/ModelConfigOption.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,23 +33,31 @@ export function findSupportedEffort(
return options.find(o => o.reasoningEffort === effort)?.reasoningEffort;
}

export function createModelConfigOption(
availableModels: Array<Model>,
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<Model>, currentBaseModelId: string | null): Array<ModelOption> {
const options: Array<ModelOption> = 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<Model>,
currentBaseModelId: string,
recommendedModelId?: string,
): SessionConfigOption {
const options = modelOptions(availableModels, currentBaseModelId);

const recommendation = recommendedModelId && options.some(option => option.value === recommendedModelId)
? recommendedModelId
Expand Down Expand Up @@ -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<Model>,
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],
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
2 changes: 1 addition & 1 deletion src/__tests__/CodexACPAgent/async-tasks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions src/__tests__/CodexACPAgent/collab-agent-events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ describe("CodexEventHandler - collab agent tool call events", () => {
true,
new ACPSessionConnection(mockFixture.getAcpConnection(), sessionId),
() => {},
() => ({currentModel: null, availableModels: []}),
);
return response;
}
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -1751,6 +1755,7 @@ describe("CodexEventHandler - collab agent tool call events", () => {
true,
new ACPSessionConnection(mockFixture.getAcpConnection(), sessionId),
() => {},
() => ({currentModel: null, availableModels: []}),
);
const spawn = (item: Record<string, unknown>) => router.handle({
method: "item/started",
Expand Down Expand Up @@ -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<string, unknown>) => router.handle({
method: "item/started",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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));
Expand Down
35 changes: 35 additions & 0 deletions src/__tests__/CodexACPAgent/data/subagent-model-activity.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
]
}
}
]
76 changes: 76 additions & 0 deletions src/__tests__/CodexACPAgent/data/subagent-model-reopen.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
]
}
}
]
Loading
Loading