diff --git a/README.md b/README.md index 88a2ad51..975d7ea5 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ Use [OpenAI Codex](https://github.com/openai/codex) from [Agent Client Protocol] - [Background terminal tasks](docs/async-tasks.md) in AIR, with task status and targeted stop support after capability negotiation. - Session-scoped long-running goals through the provider-neutral [goal extension](docs/goal-extension.md). - A per-turn [agent file-change report](docs/agent-file-change-report.md) after capability negotiation. +- [Turn configuration receipts](docs/turn-configuration-receipt.md) in prompt response metadata. - Client-provided MCP servers over command-based stdio config and HTTP transport. - Slash commands: `/status`, `/mcp`, `/skills`, `/goal`, `/review`, `/review-branch`, `/review-commit`, `/compact`, and `/logout`, as well as configured skills. diff --git a/docs/turn-configuration-receipt.md b/docs/turn-configuration-receipt.md new file mode 100644 index 00000000..14072ba5 --- /dev/null +++ b/docs/turn-configuration-receipt.md @@ -0,0 +1,52 @@ +# Turn configuration receipt + +`codex-acp` returns transport-level model configuration evidence in each +`PromptResponse` that started at least one Codex turn: + +```json +{ + "_meta": { + "codex": { + "turnConfiguration": { + "version": 1, + "turns": [ + { + "threadId": "thread-id", + "turnId": "turn-id", + "requested": { + "model": "gpt-5.6-sol", + "effort": "xhigh" + }, + "threadSettings": { + "model": "gpt-5.6-sol", + "effort": "xhigh", + "modelProvider": "openai" + }, + "modelReroutes": [] + } + ] + } + } + } +} +``` + +- `requested` is the exact model and effort sent by the adapter in + `turn/start`. It is `null` for command-started turns such as `/review` and + `/goal`, whose app-server request carries no model or effort fields. +- `threadSettings` is the latest `thread/settings/updated` value observed from + the Codex app server when the prompt response is built. It is `null` when the + app server has not reported settings for that thread. +- `modelReroutes` records every `model/rerouted` notification observed for the + turn, in order. + +One ACP prompt can start multiple Codex turns, for example when an approved plan +continues into implementation. Each turn gets its own entry. Cancelled and +typed-failure responses retain entries for turns that had already started. + +This receipt replaces model self-report with transport-observed configuration +evidence. `requested` is authoritative only for fields explicitly sent on that +turn; `threadSettings` reports the app server's latest settings for command-started +turns. The receipt does not claim to be a backend execution attestation: the +current Codex app server protocol does not expose the final per-turn reasoning +effort after request processing. diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index 7040057f..98c6d492 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -46,6 +46,7 @@ import type { Thread, ThreadGoal, ThreadGoalStatus, + ThreadSettings, ThreadSourceKind, TurnCompletedNotification, TurnSteerResponse, @@ -1083,6 +1084,10 @@ export class CodexAcpClient { }); } + getThreadSettings(sessionId: string): ThreadSettings | undefined { + return this.codexClient.getThreadSettings(sessionId); + } + private getCollaborationMode(sessionId: string): ModeKind { return this.codexClient.getThreadSettings(sessionId)?.collaborationMode.mode ?? "default"; } diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index 4dc15e01..2fb96733 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -184,6 +184,41 @@ export interface SessionState { asyncTasks: CodexBackgroundTerminalTasks; } +type RequestedTurnConfiguration = { + model: string; + effort: ReasoningEffort | null; +}; + +type ThreadSettingsConfiguration = RequestedTurnConfiguration & { + modelProvider: string; +}; + +type TurnModelReroute = { + fromModel: string; + toModel: string; + reason: string; +}; + +type PromptTurnConfiguration = { + threadId: string; + turnId: string; + requested: RequestedTurnConfiguration | null; + threadSettings: ThreadSettingsConfiguration | null; + modelReroutes: TurnModelReroute[]; +}; + +type PendingPromptTurnConfiguration = Omit; + +type PromptMeta = { + quota: QuotaMeta; + codex?: { + turnConfiguration: { + version: 1; + turns: PromptTurnConfiguration[]; + }; + }; +}; + export type SessionFailureCategory = | "connection" | "access" | "limit" | "request" | "service" | "unknown"; @@ -2751,6 +2786,20 @@ export class CodexAcpServer { let agentFileChangeReportUnavailableReason: AgentFileChangeReportUnavailableReason = "providerError"; let promptWasCancelled = false; let recoverableSessionFailure = sessionState.sessionFailure; + const promptTurns = new Map(); + const modelReroutes = new Map(); + const turnKey = (threadId: string, turnId: string): string => `${threadId}\u0000${turnId}`; + const recordTurnStarted = (threadId: string, turnId: string, modelId: ModelId | null): void => { + promptTurns.set(turnKey(threadId, turnId), { + threadId, + turnId, + requested: modelId === null ? null : { + model: modelId.model, + effort: modelId.effort as ReasoningEffort, + }, + }); + }; + const promptMeta = (): PromptMeta => this.buildPromptMeta(sessionState, promptTurns, modelReroutes); sessionState.currentTurnId = null; const activePrompt = this.trackActivePrompt(params.sessionId); let pendingTurnStart: PendingTurnStart | null = null; @@ -2778,7 +2827,7 @@ export class CodexAcpServer { promptWasCancelled = true; agentFileChangeReportTurnId = null; agentFileChangeReportUnavailableReason = "cancelled"; - return this.cancelledPromptResponse(sessionState); + return this.cancelledPromptResponse(sessionState, promptMeta()); }; try { @@ -2812,6 +2861,16 @@ export class CodexAcpServer { await this.codexAcpClient.subscribeToSessionEvents(params.sessionId, async (event) => { await observeInteraction(event); + if (event.method === "model/rerouted") { + const key = turnKey(event.params.threadId, event.params.turnId); + const reroutes = modelReroutes.get(key) ?? []; + reroutes.push({ + fromModel: event.params.fromModel, + toModel: event.params.toModel, + reason: event.params.reason, + }); + modelReroutes.set(key, reroutes); + } if (!promptNotificationsActive) { await promptEventHandler.handleSessionScopedNotification(event); return; @@ -2842,6 +2901,7 @@ export class CodexAcpServer { ensurePendingTurnStart(); }, onTurnStarted: (turnId, threadId) => { + recordTurnStarted(threadId, turnId, null); const turn = {threadId, turnId}; activePrompt.currentTurn = turn; if (this.promptShouldStop(params.sessionId, activePrompt)) { @@ -2899,6 +2959,7 @@ export class CodexAcpServer { sessionState, eventHandler, commandResult.turnCompleted?.turn.id ?? sessionState.currentTurnId, + promptMeta(), ); if (terminalFailure) { return terminalFailure; @@ -2912,7 +2973,7 @@ export class CodexAcpServer { return { stopReason: "end_turn", usage: this.buildPromptUsage(sessionState.lastTokenUsage), - _meta: this.buildQuotaMeta(sessionState), + _meta: promptMeta(), }; } @@ -2956,6 +3017,7 @@ export class CodexAcpServer { sessionState.cwd, sessionState.additionalDirectories, (turnId) => { + recordTurnStarted(params.sessionId, turnId, modelId); const turn = {threadId: params.sessionId, turnId}; activePrompt.currentTurn = turn; if (this.promptShouldStop(params.sessionId, activePrompt)) { @@ -3012,6 +3074,7 @@ export class CodexAcpServer { sessionState, eventHandler, turnCompleted.turn.id, + promptMeta(), ); if (terminalFailure) { return terminalFailure; @@ -3056,6 +3119,7 @@ export class CodexAcpServer { sessionState.cwd, sessionState.additionalDirectories, (turnId) => { + recordTurnStarted(params.sessionId, turnId, modelId); const turn = {threadId: params.sessionId, turnId}; activePrompt.currentTurn = turn; if (this.promptShouldStop(params.sessionId, activePrompt)) { @@ -3113,6 +3177,7 @@ export class CodexAcpServer { sessionState, eventHandler, turnCompleted.turn.id, + promptMeta(), ); if (implementationFailure) { return implementationFailure; @@ -3146,7 +3211,7 @@ export class CodexAcpServer { return { stopReason: "end_turn", usage: this.buildPromptUsage(sessionState.lastTokenUsage), - _meta: this.buildQuotaMeta(sessionState), + _meta: promptMeta(), }; } catch (err) { logger.error(`Prompt for session ${params.sessionId} failed`, err); @@ -3169,6 +3234,7 @@ export class CodexAcpServer { sessionState, eventHandler, sessionState.currentTurnId, + promptMeta(), true, ); if (failureResponse !== null) { @@ -3243,11 +3309,11 @@ export class CodexAcpServer { } } - private cancelledPromptResponse(sessionState: SessionState): acp.PromptResponse { + private cancelledPromptResponse(sessionState: SessionState, meta?: PromptMeta): acp.PromptResponse { return { stopReason: "cancelled", usage: this.buildPromptUsage(sessionState.lastTokenUsage), - _meta: this.buildQuotaMeta(sessionState), + _meta: meta ?? this.buildPromptMeta(sessionState), }; } @@ -3255,6 +3321,7 @@ export class CodexAcpServer { sessionState: SessionState, eventHandler: CodexEventHandler, turnId: string | null, + meta?: PromptMeta, allowUnattributed = false, ): acp.PromptResponse | null { const failureMeta = eventHandler.getTerminalSessionFailureMeta(turnId, allowUnattributed); @@ -3265,13 +3332,17 @@ export class CodexAcpServer { stopReason: "end_turn", usage: this.buildPromptUsage(sessionState.lastTokenUsage), _meta: { - ...this.buildQuotaMeta(sessionState), + ...(meta ?? this.buildPromptMeta(sessionState)), ...failureMeta, }, }; } - private buildQuotaMeta(sessionState: SessionState): { quota: QuotaMeta } { + private buildPromptMeta( + sessionState: SessionState, + promptTurns: ReadonlyMap = new Map(), + modelReroutes: ReadonlyMap = new Map(), + ): PromptMeta { const lastTokenUsage = sessionState.lastTokenUsage; // Remove the "[reasoning-level]" suffix from currentModelId if present @@ -3282,12 +3353,34 @@ export class CodexAcpServer { ? [{ model: modelName, token_count: lastTokenUsage }] : []; - return { + const meta: PromptMeta = { quota: { token_count: sessionState.lastTokenUsage, model_usage: modelUsage } }; + if (promptTurns.size === 0) { + return meta; + } + + meta.codex = { + turnConfiguration: { + version: 1, + turns: [...promptTurns.entries()].map(([key, turn]) => { + const threadSettings = this.codexAcpClient.getThreadSettings(turn.threadId); + return { + ...turn, + threadSettings: threadSettings === undefined ? null : { + model: threadSettings.model, + effort: threadSettings.effort, + modelProvider: threadSettings.modelProvider, + }, + modelReroutes: modelReroutes.get(key) ?? [], + }; + }), + }, + }; + return meta; } private buildPromptUsage(lastTokenUsage: TokenCount | null): acp.Usage | null { diff --git a/src/__tests__/CodexACPAgent/data/token-usage-cancelled.json b/src/__tests__/CodexACPAgent/data/token-usage-cancelled.json index 426011ab..18de1cc7 100644 --- a/src/__tests__/CodexACPAgent/data/token-usage-cancelled.json +++ b/src/__tests__/CodexACPAgent/data/token-usage-cancelled.json @@ -28,6 +28,23 @@ } } ] + }, + "codex": { + "turnConfiguration": { + "version": 1, + "turns": [ + { + "threadId": "test-session-id", + "turnId": "turn-id", + "requested": { + "model": "model-id", + "effort": "effort" + }, + "threadSettings": null, + "modelReroutes": [] + } + ] + } } } } diff --git a/src/__tests__/CodexACPAgent/data/token-usage-end-turn.json b/src/__tests__/CodexACPAgent/data/token-usage-end-turn.json index 60ba2e2e..d77de7a0 100644 --- a/src/__tests__/CodexACPAgent/data/token-usage-end-turn.json +++ b/src/__tests__/CodexACPAgent/data/token-usage-end-turn.json @@ -28,6 +28,23 @@ } } ] + }, + "codex": { + "turnConfiguration": { + "version": 1, + "turns": [ + { + "threadId": "test-session-id", + "turnId": "turn-id", + "requested": { + "model": "model-id", + "effort": "effort" + }, + "threadSettings": null, + "modelReroutes": [] + } + ] + } } } } diff --git a/src/__tests__/CodexACPAgent/data/token-usage-multiple-updates.json b/src/__tests__/CodexACPAgent/data/token-usage-multiple-updates.json index aebc1783..93ab77b4 100644 --- a/src/__tests__/CodexACPAgent/data/token-usage-multiple-updates.json +++ b/src/__tests__/CodexACPAgent/data/token-usage-multiple-updates.json @@ -28,6 +28,23 @@ } } ] + }, + "codex": { + "turnConfiguration": { + "version": 1, + "turns": [ + { + "threadId": "test-session-id", + "turnId": "turn-id", + "requested": { + "model": "model-id", + "effort": "effort" + }, + "threadSettings": null, + "modelReroutes": [] + } + ] + } } } } diff --git a/src/__tests__/CodexACPAgent/data/token-usage-null.json b/src/__tests__/CodexACPAgent/data/token-usage-null.json index 65196087..6b974ff4 100644 --- a/src/__tests__/CodexACPAgent/data/token-usage-null.json +++ b/src/__tests__/CodexACPAgent/data/token-usage-null.json @@ -5,6 +5,23 @@ "quota": { "token_count": null, "model_usage": [] + }, + "codex": { + "turnConfiguration": { + "version": 1, + "turns": [ + { + "threadId": "test-session-id", + "turnId": "turn-id", + "requested": { + "model": "model-id", + "effort": "effort" + }, + "threadSettings": null, + "modelReroutes": [] + } + ] + } } } } diff --git a/src/__tests__/CodexACPAgent/data/turn-configuration-receipt.json b/src/__tests__/CodexACPAgent/data/turn-configuration-receipt.json new file mode 100644 index 00000000..cf4eb0a4 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/turn-configuration-receipt.json @@ -0,0 +1,27 @@ +{ + "turnConfiguration": { + "version": 1, + "turns": [ + { + "threadId": "test-session-id", + "turnId": "turn-id", + "requested": { + "model": "gpt-5.6-sol", + "effort": "xhigh" + }, + "threadSettings": { + "model": "gpt-5.6-sol", + "effort": "xhigh", + "modelProvider": "openai" + }, + "modelReroutes": [ + { + "fromModel": "gpt-5.6-sol", + "toModel": "gpt-5.6-terra", + "reason": "highRiskCyberActivity" + } + ] + } + ] + } +} diff --git a/src/__tests__/CodexACPAgent/plan-review-events.test.ts b/src/__tests__/CodexACPAgent/plan-review-events.test.ts index 67dbfc73..13f4f77a 100644 --- a/src/__tests__/CodexACPAgent/plan-review-events.test.ts +++ b/src/__tests__/CodexACPAgent/plan-review-events.test.ts @@ -63,6 +63,7 @@ describe("CodexACPAgent - plan review", () => { emitCompletionNotification?: boolean; implementationStart?: Promise; permissionResponse?: acp.RequestPermissionResponse | Promise; + initialModelId?: string; } = {}, ) { await fixture.getCodexAcpAgent().initialize({ @@ -81,6 +82,7 @@ describe("CodexACPAgent - plan review", () => { const sessionState = createTestSessionState({ sessionId, collaborationMode: PLAN_COLLABORATION_MODE, + ...(options.initialModelId === undefined ? {} : {currentModelId: options.initialModelId}), }); vi.spyOn(fixture.getCodexAcpAgent(), "getSessionState").mockReturnValue(sessionState); @@ -233,6 +235,49 @@ describe("CodexACPAgent - plan review", () => { expect(turnStart).toHaveBeenCalledTimes(2); }); + it("receipts the captured model when settings change during plan approval", async () => { + const permission = deferred(); + const {promptPromise, sessionState, turnStart, implementationTurn} = await startPlanPrompt( + "implement_plan", + { + initialModelId: "gpt-5.6-sol[xhigh]", + permissionResponse: permission.promise, + }, + ); + + sessionState.currentModelId = "gpt-5.6-terra[medium]"; + permission.resolve({outcome: {outcome: "selected", optionId: "implement_plan"}}); + await vi.waitFor(() => expect(turnStart).toHaveBeenCalledTimes(2)); + expect(turnStart.mock.calls[1]![0]).toMatchObject({ + model: "gpt-5.6-sol", + effort: "xhigh", + }); + + implementationTurn.resolve({ + threadId: sessionId, + turn: { + id: "implementation-turn", + items: [], + itemsView: "notLoaded", + status: "completed", + error: null, + startedAt: null, + completedAt: null, + durationMs: null, + }, + }); + const response = await promptPromise; + + expect(response._meta?.["codex"]).toMatchObject({ + turnConfiguration: { + turns: [ + {turnId: "plan-turn", requested: {model: "gpt-5.6-sol", effort: "xhigh"}}, + {turnId: "implementation-turn", requested: {model: "gpt-5.6-sol", effort: "xhigh"}}, + ], + }, + }); + }); + it.each([ ["revise_plan", "rejected"], [null, "cancelled"], diff --git a/src/__tests__/CodexACPAgent/turn-configuration-receipt.test.ts b/src/__tests__/CodexACPAgent/turn-configuration-receipt.test.ts new file mode 100644 index 00000000..e65ba13d --- /dev/null +++ b/src/__tests__/CodexACPAgent/turn-configuration-receipt.test.ts @@ -0,0 +1,145 @@ +import {beforeEach, describe, expect, it, vi} from "vitest"; +import {createCodexMockTestFixture, createTestSessionState, type CodexMockTestFixture} from "../acp-test-utils"; + +describe("PromptResponse turn configuration receipt", () => { + let fixture: CodexMockTestFixture; + const sessionId = "test-session-id"; + + beforeEach(() => { + fixture = createCodexMockTestFixture(); + vi.clearAllMocks(); + }); + + it("reports requested model settings, observed thread settings, and reroutes", async () => { + const agent = fixture.getCodexAcpAgent(); + const appServer = fixture.getCodexAppServerClient(); + const turn = {id: "turn-id", items: [], status: "inProgress" as const, error: null}; + + const turnStart = vi.spyOn(appServer, "turnStart").mockResolvedValue({turn} as never); + vi.spyOn(appServer, "awaitTurnCompleted").mockImplementation(async () => { + fixture.sendServerNotification({ + method: "thread/settings/updated", + params: { + threadId: sessionId, + threadSettings: { + cwd: "/test/cwd", + approvalPolicy: "on-request", + approvalsReviewer: "user", + sandboxPolicy: {type: "workspaceWrite", writableRoots: [], networkAccess: false}, + activePermissionProfile: null, + model: "gpt-5.6-sol", + modelProvider: "openai", + serviceTier: null, + effort: "xhigh", + summary: "auto", + collaborationMode: {mode: "default", settings: {}}, + personality: null, + }, + }, + }); + fixture.sendServerNotification({ + method: "model/rerouted", + params: { + threadId: sessionId, + turnId: turn.id, + fromModel: "gpt-5.6-sol", + toModel: "gpt-5.6-terra", + reason: "highRiskCyberActivity", + }, + }); + return { + threadId: sessionId, + turn: {...turn, status: "completed" as const}, + } as never; + }); + vi.spyOn(agent, "getSessionState").mockReturnValue(createTestSessionState({ + sessionId, + currentModelId: "gpt-5.6-sol[xhigh]", + })); + + const response = await agent.prompt({ + sessionId, + prompt: [{type: "text", text: "test prompt"}], + }); + + expect(turnStart).toHaveBeenCalledWith(expect.objectContaining({ + model: "gpt-5.6-sol", + effort: "xhigh", + })); + await expect(`${JSON.stringify(response._meta?.["codex"], null, 2)}\n`).toMatchFileSnapshot( + "data/turn-configuration-receipt.json", + ); + }); + + it("marks thread settings unavailable when the app server did not report them", async () => { + const agent = fixture.getCodexAcpAgent(); + const appServer = fixture.getCodexAppServerClient(); + const turn = {id: "turn-id", items: [], status: "inProgress" as const, error: null}; + + vi.spyOn(appServer, "turnStart").mockResolvedValue({turn} as never); + vi.spyOn(appServer, "awaitTurnCompleted").mockResolvedValue({ + threadId: sessionId, + turn: {...turn, status: "completed" as const}, + } as never); + vi.spyOn(agent, "getSessionState").mockReturnValue(createTestSessionState({ + sessionId, + currentModelId: "gpt-5.6-terra[medium]", + })); + + const response = await agent.prompt({ + sessionId, + prompt: [{type: "text", text: "test prompt"}], + }); + + expect(response._meta?.["codex"]).toEqual({ + turnConfiguration: { + version: 1, + turns: [{ + threadId: sessionId, + turnId: turn.id, + requested: {model: "gpt-5.6-terra", effort: "medium"}, + threadSettings: null, + modelReroutes: [], + }], + }, + }); + }); + + it("marks command-started turns as having no explicit requested configuration", async () => { + const agent = fixture.getCodexAcpAgent(); + const appServer = fixture.getCodexAppServerClient(); + const reviewThreadId = "review-thread-id"; + const turn = {id: "review-turn-id", items: [], status: "inProgress" as const, error: null}; + + vi.spyOn(appServer, "reviewStart").mockResolvedValue({ + reviewThreadId, + turn, + } as never); + vi.spyOn(appServer, "awaitTurnCompleted").mockResolvedValue({ + threadId: reviewThreadId, + turn: {...turn, status: "completed" as const}, + } as never); + vi.spyOn(agent, "getSessionState").mockReturnValue(createTestSessionState({ + sessionId, + currentModelId: "gpt-5.6-terra[medium]", + })); + + const response = await agent.prompt({ + sessionId, + prompt: [{type: "text", text: "/review"}], + }); + + expect(response._meta?.["codex"]).toEqual({ + turnConfiguration: { + version: 1, + turns: [{ + threadId: reviewThreadId, + turnId: turn.id, + requested: null, + threadSettings: null, + modelReroutes: [], + }], + }, + }); + }); +});