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
2 changes: 1 addition & 1 deletion docs/RELEASES.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ Once the workflow finishes, confirm both outputs landed:

```sh
gh release view "v<version>"
npm view "@agentclientprotocol/codex-acp@<version>"
npm view "$(node -p "require('./package.json').name")@<version>"
```

## How the version is chosen
Expand Down
1 change: 1 addition & 0 deletions src/AcpExtensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export type LegacyNewSessionResponse = NewSessionResponse & {
}

export type LegacyLoadSessionResponse = LoadSessionResponse & {
sessionId: SessionId;
models?: LegacySessionModelState | null;
}

Expand Down
35 changes: 28 additions & 7 deletions src/CodexAcpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -535,7 +535,7 @@ export class CodexAcpClient {
excludeTurns: true,
config: await this.createSessionConfig(request.cwd, additionalDirectories, request.mcpServers ?? []),
cwd: request.cwd,
modelProvider: await this.getResumeModelProvider(),
...(await this.resumeModelProviderParams()),
threadId: request.sessionId,
});
onSubscribed?.();
Expand All @@ -559,7 +559,7 @@ export class CodexAcpClient {
refreshSkills: (cwd, directories) => this.refreshSkills(cwd, directories),
createSessionConfig: (cwd, directories, mcpServers) =>
this.createSessionConfig(cwd, directories, mcpServers),
getResumeModelProvider: () => this.getResumeModelProvider(),
getResumeModelProviderParams: () => this.resumeModelProviderParams(),
fetchAvailableModels: () => this.fetchAvailableModels(),
createCurrentModelId: (models, model, reasoningEffort) =>
this.createModelId(models, model, reasoningEffort).toString(),
Expand All @@ -575,7 +575,7 @@ export class CodexAcpClient {
excludeTurns: true,
config: await this.createSessionConfig(request.cwd, additionalDirectories, request.mcpServers ?? []),
cwd: request.cwd,
modelProvider: await this.getResumeModelProvider(),
...(await this.resumeModelProviderParams()),
threadId: request.sessionId,
});
onSubscribed?.();
Expand Down Expand Up @@ -797,10 +797,17 @@ export class CodexAcpClient {
return this.gatewayConfig?.modelProvider ?? this.modelProvider;
}

private async getResumeModelProvider(): Promise<string> {
// Prefer an explicit/gateway provider, then the provider persisted in Codex config.
// Keep OpenAI as the final fallback for ChatGPT-authenticated sessions without a configured provider.
return (await this.getCurrentModelProvider()) ?? "openai";
/**
* Resume-time provider override, as `thread/resume` params.
*
* Prefer an explicit/gateway provider, then the provider persisted in Codex config.
* When neither is configured the field is omitted entirely: supplying one makes the
* app-server re-resolve the thread's model and reasoning effort from config, which
* discards the picks stored on the thread itself.
*/
private async resumeModelProviderParams(): Promise<{modelProvider?: string}> {
const modelProvider = await this.getCurrentModelProvider();
return modelProvider ? {modelProvider} : {};
}

private async refreshSkills(
Expand Down Expand Up @@ -1083,6 +1090,20 @@ export class CodexAcpClient {
});
}

async setModelAndEffort(
sessionId: string,
currentModelId: string,
collaborationMode: ModeKind,
): Promise<void> {
const modelId = ModelId.fromString(currentModelId);
await this.codexClient.threadSettingsUpdate({
threadId: sessionId,
model: modelId.model,
effort: modelId.effort as ReasoningEffort,
collaborationMode: createCodexCollaborationMode(collaborationMode, currentModelId),
});
}

private getCollaborationMode(sessionId: string): ModeKind {
return this.codexClient.getThreadSettings(sessionId)?.collaborationMode.mode ?? "default";
}
Expand Down
123 changes: 89 additions & 34 deletions src/CodexAcpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,7 @@ export class CodexAcpServer {
private readonly sessionGenerations: Map<string, number>;
private readonly sessionOpenGenerations: Map<string, number>;
private readonly goalControlGenerations: Map<string, number>;
private readonly sessionConfigUpdates: Map<string, Promise<unknown>>;
private readonly permissionLifecycleContexts: WeakMap<SessionState, PermissionLifecycleContext>;
private readonly codexProcessState: CodexProcessState | null;
private codexProcessGeneration = 0;
Expand All @@ -303,6 +304,7 @@ export class CodexAcpServer {
this.sessionGenerations = new Map();
this.sessionOpenGenerations = new Map();
this.goalControlGenerations = new Map();
this.sessionConfigUpdates = new Map();
this.permissionLifecycleContexts = new WeakMap();
this.connection = connection;
this.codexAcpClient = codexAcpClient;
Expand Down Expand Up @@ -795,6 +797,7 @@ export class CodexAcpServer {
availableModelCount: modelState.availableModels.length
});
return {
sessionId,
models: modelState,
modes: modeState,
...this.createSessionConfigOptionsResponse(this.getSessionState(sessionId)),
Expand Down Expand Up @@ -1327,7 +1330,10 @@ export class CodexAcpServer {
const sessionState = this.sessions.get(params.sessionId);
if (!sessionState) throw new Error(`Session ${params.sessionId} not found`);

await this.applySessionConfigOption(sessionState, params);
await this.runSessionConfigUpdate(
params.sessionId,
() => this.applySessionConfigOption(sessionState, params),
);

return {
configOptions: this.createSessionConfigOptions(sessionState),
Expand All @@ -1346,16 +1352,29 @@ export class CodexAcpServer {
await this.applyCollaborationModeChange(sessionState, this.stringConfigValue(params));
break;
case MODEL_CONFIG_ID:
this.applyModelChange(sessionState, this.stringConfigValue(params));
await this.applyModelChange(sessionState, this.stringConfigValue(params));
break;
case REASONING_EFFORT_CONFIG_ID:
this.applyReasoningEffortChange(sessionState, this.stringConfigValue(params));
await this.applyReasoningEffortChange(sessionState, this.stringConfigValue(params));
break;
default:
throw RequestError.invalidParams();
}
}

private async runSessionConfigUpdate<T>(sessionId: string, operation: () => Promise<T>): Promise<T> {
const previous = this.sessionConfigUpdates.get(sessionId);
const update = previous ? previous.then(operation, operation) : operation();
this.sessionConfigUpdates.set(sessionId, update);
try {
return await update;
} finally {
if (this.sessionConfigUpdates.get(sessionId) === update) {
this.sessionConfigUpdates.delete(sessionId);
}
}
}

private applyFastModeChange(sessionState: SessionState, params: acp.SetSessionConfigOptionRequest): void {
const value = params.value;
if (typeof value === "boolean") {
Expand Down Expand Up @@ -1392,7 +1411,7 @@ export class CodexAcpServer {
sessionState.collaborationMode = mode;
}

private applyModelChange(sessionState: SessionState, value: string): void {
private async applyModelChange(sessionState: SessionState, value: string): Promise<void> {
const model = sessionState.availableModels.find(m => m.id === value);
if (!model) {
const currentModel = ModelId.fromString(sessionState.currentModelId).model;
Expand All @@ -1404,16 +1423,27 @@ export class CodexAcpServer {
const currentEffort = ModelId.fromString(sessionState.currentModelId).effort;
const effort = findSupportedEffort(model.supportedReasoningEfforts, currentEffort)
?? model.defaultReasoningEffort;
await this.codexAcpClient.setModelAndEffort(
sessionState.sessionId,
ModelId.fromComponents(model, effort).toString(),
sessionState.collaborationMode,
);
this.applyModelAndEffort(sessionState, model, effort);
}

private applyReasoningEffortChange(sessionState: SessionState, value: string): void {
private async applyReasoningEffortChange(sessionState: SessionState, value: string): Promise<void> {
const effort = findSupportedEffort(sessionState.supportedReasoningEfforts, value);
if (!effort) {
throw RequestError.invalidParams();
}
const {model} = ModelId.fromString(sessionState.currentModelId);
sessionState.currentModelId = ModelId.create(model, effort).toString();
const currentModelId = ModelId.create(model, effort).toString();
await this.codexAcpClient.setModelAndEffort(
sessionState.sessionId,
currentModelId,
sessionState.collaborationMode,
);
sessionState.currentModelId = currentModelId;
}

private applyModelAndEffort(sessionState: SessionState, model: Model, effort: ReasoningEffort): void {
Expand All @@ -1424,34 +1454,41 @@ export class CodexAcpServer {
}

async unstable_setSessionModel(params: LegacySetSessionModelRequest): Promise<LegacySetSessionModelResponse> {
logger.log("Set session model requested", {
sessionId: params.sessionId,
modelId: params.modelId
});
const sessionState = this.sessions.get(params.sessionId);
if (!sessionState) throw new Error(`Session ${params.sessionId} not found`);
return await this.runSessionConfigUpdate(params.sessionId, async () => {
logger.log("Set session model requested", {
sessionId: params.sessionId,
modelId: params.modelId
});
const sessionState = this.sessions.get(params.sessionId);
if (!sessionState) throw new Error(`Session ${params.sessionId} not found`);

const {model: requestedModelName, effort: requestedEffort} = ModelId.fromString(params.modelId);
const {model: requestedModelName, effort: requestedEffort} = ModelId.fromString(params.modelId);

const models = await this.codexAcpClient.fetchAvailableModels();
const model = models.find(m => m.id === requestedModelName);
if (!model) throw new Error(`Unknown model ${params.modelId}`);
const models = await this.codexAcpClient.fetchAvailableModels();
const model = models.find(m => m.id === requestedModelName);
if (!model) throw new Error(`Unknown model ${params.modelId}`);

let reasoningEffort: ReasoningEffort;
if (requestedEffort) {
const matchedEffort = findSupportedEffort(model.supportedReasoningEfforts, requestedEffort);
if (!matchedEffort) {
throw new Error(`Unsupported reasoning effort ${requestedEffort} for model ${requestedModelName}`);
let reasoningEffort: ReasoningEffort;
if (requestedEffort) {
const matchedEffort = findSupportedEffort(model.supportedReasoningEfforts, requestedEffort);
if (!matchedEffort) {
throw new Error(`Unsupported reasoning effort ${requestedEffort} for model ${requestedModelName}`);
}
reasoningEffort = matchedEffort;
} else {
reasoningEffort = model.defaultReasoningEffort;
}
reasoningEffort = matchedEffort;
} else {
reasoningEffort = model.defaultReasoningEffort;
}

sessionState.availableModels = models;
this.applyModelAndEffort(sessionState, model, reasoningEffort);
await this.codexAcpClient.setModelAndEffort(
sessionState.sessionId,
ModelId.fromComponents(model, reasoningEffort).toString(),
sessionState.collaborationMode,
);
sessionState.availableModels = models;
this.applyModelAndEffort(sessionState, model, reasoningEffort);

return {};
return {};
});
}

private parseLegacySetSessionModelParams(params: Record<string, unknown>): LegacySetSessionModelRequest {
Expand Down Expand Up @@ -2739,6 +2776,18 @@ export class CodexAcpServer {
if (this.providerUpdate !== null) {
await this.providerUpdate;
}
const pendingConfigUpdate = this.sessionConfigUpdates.get(params.sessionId);
if (pendingConfigUpdate !== undefined) {
try {
await pendingConfigUpdate;
} catch (error) {
logger.error(`Pending session configuration update failed for ${params.sessionId}`, error);
throw RequestError.invalidRequest(
undefined,
"Prompt blocked because a pending session configuration update failed",
);
}
}
logger.log("Prompt received", {
sessionId: params.sessionId,
prompt: params.prompt,
Expand Down Expand Up @@ -2853,11 +2902,14 @@ export class CodexAcpServer {
onTurnStarted?.();
},
setConfigOption: async (configId, value) => {
await this.applySessionConfigOption(sessionState, {
sessionId: sessionState.sessionId,
configId,
value,
});
await this.runSessionConfigUpdate(
sessionState.sessionId,
() => this.applySessionConfigOption(sessionState, {
sessionId: sessionState.sessionId,
configId,
value,
}),
);
const session = new ACPSessionConnection(this.connection, sessionState.sessionId);
await session.update({
sessionUpdate: "config_option_update",
Expand Down Expand Up @@ -3033,7 +3085,10 @@ export class CodexAcpServer {
return cancelledPromptResponse();
}
if (approved && !this.promptShouldStop(params.sessionId, activePrompt)) {
await this.applyCollaborationModeChange(sessionState, DEFAULT_COLLABORATION_MODE);
await this.runSessionConfigUpdate(
sessionState.sessionId,
() => this.applyCollaborationModeChange(sessionState, DEFAULT_COLLABORATION_MODE),
);
const session = new ACPSessionConnection(this.connection, sessionState.sessionId);
await session.update({
sessionUpdate: "config_option_update",
Expand Down
9 changes: 6 additions & 3 deletions src/CodexAppServerClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {
ClientRequest,
InitializeParams,
InitializeResponse,
ReasoningEffort,
ServerNotification
} from "./app-server";
import type {
Expand Down Expand Up @@ -562,7 +563,7 @@ export class CodexAppServerClient {
return this.threadSettings.get(threadId);
}

async threadSettingsUpdate(params: ExperimentalThreadSettingsUpdateParams): Promise<void> {
async threadSettingsUpdate(params: ThreadSettingsUpdateParams): Promise<void> {
await this.connection.sendRequest("thread/settings/update", params);
}

Expand Down Expand Up @@ -1085,9 +1086,11 @@ type DistributiveOmit<T, K extends keyof any> = T extends any
? Omit<T, K>
: never;

export interface ExperimentalThreadSettingsUpdateParams {
export interface ThreadSettingsUpdateParams {
threadId: string;
collaborationMode: {
model?: string;
effort?: ReasoningEffort;
collaborationMode?: {
mode: "default" | "plan";
settings: {
model: string;
Expand Down
4 changes: 2 additions & 2 deletions src/SessionFork.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export type SessionForkDependencies = {
additionalDirectories: string[],
mcpServers: acp.McpServer[],
): Promise<NonNullable<ThreadForkParams["config"]>>;
getResumeModelProvider(): Promise<string>;
getResumeModelProviderParams(): Promise<{modelProvider?: string}>;
fetchAvailableModels(): Promise<Model[]>;
createCurrentModelId(models: Model[], model: string, reasoningEffort: string | null): string;
getCollaborationMode(sessionId: string): ModeKind;
Expand All @@ -37,7 +37,7 @@ export async function forkSession(
),
cwd: request.cwd,
...(lastTurnId !== undefined && {lastTurnId}),
modelProvider: await dependencies.getResumeModelProvider(),
...(await dependencies.getResumeModelProviderParams()),
threadId: request.sessionId,
});
await dependencies.codexClient.threadUnsubscribe({threadId: response.thread.id});
Expand Down
Loading