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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Use [OpenAI Codex](https://github.com/openai/codex) from [Agent Client Protocol]

- ChatGPT, API key, and client-provided custom gateway authentication.
- Model, reasoning effort, fast mode, approval, and sandbox mode configuration.
- Concrete recommended model and reasoning-effort values through the opt-in [AIR recommended config values](docs/recommended-config-values-extension.md) capability.
- Text prompts, embedded context, images, resource links, and additional workspace directories.
- Shell command, file change, [permission request](docs/permission-extension.md), MCP tool call, terminal output, reasoning, plan, web search, image generation, image view, token usage, and review events.
- [Native ACP subagent sessions](docs/subagent-sessions.md) (after capability negotiation) with separate child histories and root-routed permissions; a legacy tool-call fallback otherwise.
Expand Down
53 changes: 53 additions & 0 deletions docs/recommended-config-values-extension.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Recommended config values extension

`codex-acp` implements the experimental AIR `recommendedValue` extension for
the model and reasoning-effort session config selectors. It lets clients show a
Codex recommendation independently from the session's current selection.

## Capability negotiation

The client opts in during `initialize`:

```json
{
"clientCapabilities": {
"_meta": {
"jetbrains": {
"air": {
"version": 1,
"capabilities": ["recommendedValue"]
}
}
}
}
}
```

The adapter advertises `recommendedValue` in the corresponding capability list
of its initialize response. Without negotiation, config options retain their
existing shape and contain no recommendation metadata.

## Config option metadata

When a recommendation is available, the model or effort selector contains:

```json
{
"_meta": {
"jetbrains": {
"air": {
"version": 1,
"recommendedValue": "medium"
}
}
}
}
```

The recommended model is the available model marked `isDefault` by Codex. The
recommended effort is the current model's `defaultReasoningEffort`. A value is
emitted only when it is present among that selector's advertised options.

`recommendedValue` is independent from `currentValue`: explicit user choices
remain current. When the user switches models, the effort recommendation is
recomputed from the newly selected model.
38 changes: 34 additions & 4 deletions src/AirExtension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,21 +16,51 @@ export const AIR_SESSION_FAILURE_KEY = "sessionFailure";
export const AIR_AGENT_FILE_CHANGE_REPORT_KEY = "agentFileChangeReport";
export const AIR_NATIVE_SUBAGENT_SESSIONS_KEY = "nativeSubagentSessions";
export const AIR_ASYNC_TASKS_KEY = "asyncTasks";
export const AIR_RECOMMENDED_CONFIG_VALUE_KEY = "recommendedValue";
export const AIR_ASYNC_TASKS_BACKGROUNDED_KEY = "backgrounded";
export const AIR_AGENT_FILE_CHANGE_REPORT_REQUEST_KEY = "agentFileChangeReportRequest";
export const AIR_EXTENSION_VERSION = 1;

/** Merge one AIR payload into metadata while preserving other object namespaces. */
export function withAirMeta(
meta: Record<string, unknown> | null | undefined,
key: string,
value: unknown,
): Record<string, unknown> {
const root = asRecord(meta);
const jetbrains = asRecord(root[JETBRAINS_META_KEY]);
const air = asRecord(jetbrains[AIR_META_KEY]);
return {
...root,
[JETBRAINS_META_KEY]: {
...jetbrains,
[AIR_META_KEY]: {
...air,
[AIR_EXTENSION_VERSION_KEY]: AIR_EXTENSION_VERSION,
[key]: value,
},
},
};
}

export function clientSupportsAirCapability(
capabilities: ClientCapabilities | null | undefined,
capability: string,
): boolean {
const jetbrains = capabilities?._meta?.[JETBRAINS_META_KEY] as Record<string, unknown> | undefined;
const air = jetbrains?.[AIR_META_KEY] as Record<string, unknown> | undefined;
const version = air?.[AIR_EXTENSION_VERSION_KEY];
const supported = air?.[AIR_EXTENSION_CAPABILITIES_KEY];
const meta = asRecord(capabilities?._meta);
const jetbrains = asRecord(meta[JETBRAINS_META_KEY]);
const air = asRecord(jetbrains[AIR_META_KEY]);
const version = air[AIR_EXTENSION_VERSION_KEY];
const supported = air[AIR_EXTENSION_CAPABILITIES_KEY];
return typeof version === "number"
&& Number.isInteger(version)
&& version >= AIR_EXTENSION_VERSION
&& Array.isArray(supported)
&& supported.includes(capability);
}

function asRecord(value: unknown): Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
18 changes: 16 additions & 2 deletions src/CodexAcpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ import {
AIR_AGENT_FILE_CHANGE_REPORT_KEY,
AIR_ASYNC_TASKS_KEY,
AIR_NATIVE_SUBAGENT_SESSIONS_KEY,
AIR_RECOMMENDED_CONFIG_VALUE_KEY,
AIR_EXTENSION_CAPABILITIES_KEY,
AIR_EXTENSION_VERSION,
AIR_EXTENSION_VERSION_KEY,
Expand Down Expand Up @@ -397,6 +398,7 @@ export class CodexAcpServer {
AIR_AGENT_FILE_CHANGE_REPORT_KEY,
AIR_NATIVE_SUBAGENT_SESSIONS_KEY,
AIR_ASYNC_TASKS_KEY,
AIR_RECOMMENDED_CONFIG_VALUE_KEY,
],
},
},
Expand Down Expand Up @@ -1728,14 +1730,26 @@ export class CodexAcpServer {

private createSessionConfigOptions(sessionState: SessionState): Array<acp.SessionConfigOption> {
const currentModelId = ModelId.fromString(sessionState.currentModelId);
const useRecommendedValue = clientSupportsAirCapability(
this.clientCapabilities,
AIR_RECOMMENDED_CONFIG_VALUE_KEY,
);
const currentModel = this.findCurrentModel(sessionState.availableModels, sessionState.currentModelId);
const recommendedModelId = useRecommendedValue
? sessionState.availableModels.find(model => model.isDefault)?.id
: undefined;
const configOptions = [
sessionState.agentMode.toConfigOption(),
createCollaborationModeConfigOption(sessionState.collaborationMode),
createModelConfigOption(sessionState.availableModels, currentModelId.model),
createModelConfigOption(sessionState.availableModels, currentModelId.model, recommendedModelId),
];
if (sessionState.supportedReasoningEfforts.length > 0) {
configOptions.push(
createReasoningEffortConfigOption(sessionState.supportedReasoningEfforts, currentModelId.effort),
createReasoningEffortConfigOption(
sessionState.supportedReasoningEfforts,
currentModelId.effort,
useRecommendedValue ? currentModel?.defaultReasoningEffort : undefined,
),
);
}
if (sessionState.currentModelSupportsFast) {
Expand Down
18 changes: 17 additions & 1 deletion src/ModelConfigOption.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type {SessionConfigOption} from "@agentclientprotocol/sdk";
import type {ReasoningEffort} from "./app-server";
import type {Model, ReasoningEffortOption} from "./app-server/v2";
import {AIR_RECOMMENDED_CONFIG_VALUE_KEY, withAirMeta} from "./AirExtension";

export const MODEL_CONFIG_ID = "model";
export const REASONING_EFFORT_CONFIG_ID = "reasoning_effort";
Expand All @@ -17,7 +18,11 @@ export function findSupportedEffort(
return options.find(o => o.reasoningEffort === effort)?.reasoningEffort;
}

export function createModelConfigOption(availableModels: Array<Model>, currentBaseModelId: string): SessionConfigOption {
export function createModelConfigOption(
availableModels: Array<Model>,
currentBaseModelId: string,
recommendedModelId?: string,
): SessionConfigOption {
const options: Array<{ value: string; name: string; description: string | null }> = availableModels.map(model => ({
value: model.id,
name: model.displayName,
Expand All @@ -31,6 +36,9 @@ export function createModelConfigOption(availableModels: Array<Model>, currentBa
});
}

const recommendation = recommendedModelId && options.some(option => option.value === recommendedModelId)
? recommendedModelId
: undefined;
return {
id: MODEL_CONFIG_ID,
name: "Model",
Expand All @@ -39,13 +47,18 @@ export function createModelConfigOption(availableModels: Array<Model>, currentBa
type: "select",
currentValue: currentBaseModelId,
options,
...(recommendation
? {_meta: withAirMeta(undefined, AIR_RECOMMENDED_CONFIG_VALUE_KEY, recommendation)}
: {}),
};
}

export function createReasoningEffortConfigOption(
supportedReasoningEfforts: Array<ReasoningEffortOption>,
currentEffort: string,
recommendedEffort?: string,
): SessionConfigOption {
const recommendation = findSupportedEffort(supportedReasoningEfforts, recommendedEffort);
return {
id: REASONING_EFFORT_CONFIG_ID,
name: "Reasoning effort",
Expand All @@ -58,5 +71,8 @@ export function createReasoningEffortConfigOption(
name: capitalize(option.reasoningEffort),
description: option.description,
})),
...(recommendation
? {_meta: withAirMeta(undefined, AIR_RECOMMENDED_CONFIG_VALUE_KEY, recommendation)}
: {}),
};
}
2 changes: 1 addition & 1 deletion src/__tests__/CodexACPAgent/initialize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ describe('CodexACPAgent - initialize', () => {
jetbrains: {
air: {
version: 1,
capabilities: ["sessionFailure", "agentFileChangeReport", "nativeSubagentSessions", "asyncTasks"],
capabilities: ["sessionFailure", "agentFileChangeReport", "nativeSubagentSessions", "asyncTasks", "recommendedValue"],
},
},
},
Expand Down
66 changes: 65 additions & 1 deletion src/__tests__/CodexACPAgent/session-config-options.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {describe, expect, it, vi} from "vitest";
import * as acp from "@agentclientprotocol/sdk";
import {createCodexMockTestFixture, createTestModel} from "../acp-test-utils";
import {AgentMode, MODE_CONFIG_ID} from "../../AgentMode";
import {
Expand Down Expand Up @@ -31,11 +32,16 @@ function buildModels(): {fast: Model; slow: Model} {
description: "Strong",
supportedReasoningEfforts: [lowEffort, mediumEffort],
defaultReasoningEffort: "low",
isDefault: false,
});
return {fast, slow};
}

async function createSession(currentModelId: string, availableModels: Array<Model>) {
async function createSession(
currentModelId: string,
availableModels: Array<Model>,
clientCapabilities?: acp.ClientCapabilities,
) {
const fixture = createCodexMockTestFixture();
const codexAcpAgent = fixture.getCodexAcpAgent();
const codexAcpClient = fixture.getCodexAcpClient();
Expand All @@ -50,6 +56,10 @@ async function createSession(currentModelId: string, availableModels: Array<Mode
additionalDirectories: [],
});

if (clientCapabilities) {
await codexAcpAgent.initialize({protocolVersion: acp.PROTOCOL_VERSION, clientCapabilities});
}

const response = await codexAcpAgent.newSession({cwd: "/test/cwd", mcpServers: []});
return {fixture, codexAcpAgent, codexAcpClient, response};
}
Expand All @@ -72,6 +82,7 @@ describe("Session config options", () => {
{value: "slow-model", name: "Slow model", description: "Strong"},
],
});
expect(modelOption?._meta).toBeUndefined();

const effortOption = response.configOptions?.find(o => o.id === REASONING_EFFORT_CONFIG_ID);
expect(effortOption).toMatchObject({
Expand All @@ -84,6 +95,7 @@ describe("Session config options", () => {
{value: "high", name: "High"},
],
});
expect(effortOption?._meta).toBeUndefined();

const modeOption = response.configOptions?.find(o => o.id === MODE_CONFIG_ID);
expect(modeOption).toMatchObject({
Expand Down Expand Up @@ -142,6 +154,58 @@ describe("Session config options", () => {
expect(codexAcpAgent.getSessionState("session-id").currentModelId).toBe("custom-model[high]");
});

it("advertises the default model and its effort as recommended values after negotiation", async () => {
const {fast, slow} = buildModels();
const {response} = await createSession("slow-model[medium]", [fast, slow], {
_meta: {jetbrains: {air: {version: 1, capabilities: ["recommendedValue"]}}},
});

expect(response.configOptions?.find(option => option.id === MODEL_CONFIG_ID)).toMatchObject({
currentValue: "slow-model",
_meta: {jetbrains: {air: {version: 1, recommendedValue: "fast-model"}}},
});
expect(response.configOptions?.find(option => option.id === REASONING_EFFORT_CONFIG_ID)).toMatchObject({
currentValue: "medium",
_meta: {jetbrains: {air: {version: 1, recommendedValue: "low"}}},
});
});

it("updates the recommended effort when the selected model changes", async () => {
const {fast, slow} = buildModels();
const {codexAcpAgent} = await createSession("fast-model[medium]", [fast, slow], {
_meta: {jetbrains: {air: {version: 1, capabilities: ["recommendedValue"]}}},
});

const response = await codexAcpAgent.setSessionConfigOption({
sessionId: "session-id",
configId: MODEL_CONFIG_ID,
value: "slow-model",
});

expect(response.configOptions?.find(option => option.id === MODEL_CONFIG_ID)).toMatchObject({
currentValue: "slow-model",
_meta: {jetbrains: {air: {recommendedValue: "fast-model"}}},
});
expect(response.configOptions?.find(option => option.id === REASONING_EFFORT_CONFIG_ID)).toMatchObject({
currentValue: "medium",
_meta: {jetbrains: {air: {recommendedValue: "low"}}},
});
});

it("omits a model recommendation when the catalog has no default", async () => {
const {fast, slow} = buildModels();
fast.isDefault = false;
const {response} = await createSession("slow-model[medium]", [fast, slow], {
_meta: {jetbrains: {air: {version: 1, capabilities: ["recommendedValue"]}}},
});

expect(response.configOptions?.find(option => option.id === MODEL_CONFIG_ID)?._meta).toBeUndefined();
expect(response.configOptions?.find(option => option.id === REASONING_EFFORT_CONFIG_ID)).toHaveProperty(
"_meta.jetbrains.air.recommendedValue",
"low",
);
});

it("keeps the legacy models list as combined model/effort entries", async () => {
const {fast, slow} = buildModels();
const {response} = await createSession("fast-model[medium]", [fast, slow]);
Expand Down