Skip to content
Open
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
52 changes: 52 additions & 0 deletions docs/turn-configuration-receipt.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions src/CodexAcpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import type {
Thread,
ThreadGoal,
ThreadGoalStatus,
ThreadSettings,
ThreadSourceKind,
TurnCompletedNotification,
TurnSteerResponse,
Expand Down Expand Up @@ -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";
}
Expand Down
109 changes: 101 additions & 8 deletions src/CodexAcpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PromptTurnConfiguration, "threadSettings" | "modelReroutes">;

type PromptMeta = {
quota: QuotaMeta;
codex?: {
turnConfiguration: {
version: 1;
turns: PromptTurnConfiguration[];
};
};
};

export type SessionFailureCategory =
| "connection" | "access" | "limit" | "request" | "service" | "unknown";

Expand Down Expand Up @@ -2751,6 +2786,20 @@ export class CodexAcpServer {
let agentFileChangeReportUnavailableReason: AgentFileChangeReportUnavailableReason = "providerError";
let promptWasCancelled = false;
let recoverableSessionFailure = sessionState.sessionFailure;
const promptTurns = new Map<string, PendingPromptTurnConfiguration>();
const modelReroutes = new Map<string, TurnModelReroute[]>();
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;
Expand Down Expand Up @@ -2778,7 +2827,7 @@ export class CodexAcpServer {
promptWasCancelled = true;
agentFileChangeReportTurnId = null;
agentFileChangeReportUnavailableReason = "cancelled";
return this.cancelledPromptResponse(sessionState);
return this.cancelledPromptResponse(sessionState, promptMeta());
};

try {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -2899,6 +2959,7 @@ export class CodexAcpServer {
sessionState,
eventHandler,
commandResult.turnCompleted?.turn.id ?? sessionState.currentTurnId,
promptMeta(),
);
if (terminalFailure) {
return terminalFailure;
Expand All @@ -2912,7 +2973,7 @@ export class CodexAcpServer {
return {
stopReason: "end_turn",
usage: this.buildPromptUsage(sessionState.lastTokenUsage),
_meta: this.buildQuotaMeta(sessionState),
_meta: promptMeta(),
};
}

Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -3012,6 +3074,7 @@ export class CodexAcpServer {
sessionState,
eventHandler,
turnCompleted.turn.id,
promptMeta(),
);
if (terminalFailure) {
return terminalFailure;
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -3113,6 +3177,7 @@ export class CodexAcpServer {
sessionState,
eventHandler,
turnCompleted.turn.id,
promptMeta(),
);
if (implementationFailure) {
return implementationFailure;
Expand Down Expand Up @@ -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);
Expand All @@ -3169,6 +3234,7 @@ export class CodexAcpServer {
sessionState,
eventHandler,
sessionState.currentTurnId,
promptMeta(),
true,
);
if (failureResponse !== null) {
Expand Down Expand Up @@ -3243,18 +3309,19 @@ 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),
};
}

private terminalFailurePromptResponse(
sessionState: SessionState,
eventHandler: CodexEventHandler,
turnId: string | null,
meta?: PromptMeta,
allowUnattributed = false,
): acp.PromptResponse | null {
const failureMeta = eventHandler.getTerminalSessionFailureMeta(turnId, allowUnattributed);
Expand All @@ -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<string, PendingPromptTurnConfiguration> = new Map(),
modelReroutes: ReadonlyMap<string, TurnModelReroute[]> = new Map(),
): PromptMeta {
const lastTokenUsage = sessionState.lastTokenUsage;

// Remove the "[reasoning-level]" suffix from currentModelId if present
Expand All @@ -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 {
Expand Down
17 changes: 17 additions & 0 deletions src/__tests__/CodexACPAgent/data/token-usage-cancelled.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": []
}
]
}
}
}
}
17 changes: 17 additions & 0 deletions src/__tests__/CodexACPAgent/data/token-usage-end-turn.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": []
}
]
}
}
}
}
17 changes: 17 additions & 0 deletions src/__tests__/CodexACPAgent/data/token-usage-multiple-updates.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": []
}
]
}
}
}
}
Loading