From 17ea6f96e69e5f7574fc7f7f378459bfebcf9ce0 Mon Sep 17 00:00:00 2001 From: Nikita Ashikhmin Date: Mon, 13 Jul 2026 18:35:04 +0400 Subject: [PATCH 1/5] Add plan mode command action --- src/CodexAcpClient.ts | 14 ++++- src/CodexAcpServer.ts | 47 +++++++++++++-- src/CodexAppServerClient.ts | 16 +++++ src/CodexCommands.ts | 28 +++++++++ src/CollaborationModeConfig.ts | 41 +++++++++++++ .../data/available-commands-build-in.json | 14 +++++ .../data/available-commands-skills.json | 14 +++++ .../data/load-session-history.json | 14 +++++ ...ession-response-item-history-fallback.json | 14 +++++ .../CodexACPAgent/initialize.test.ts | 16 +++++ .../session-config-options.test.ts | 60 ++++++++++++++++++- src/__tests__/acp-test-utils.ts | 2 + 12 files changed, 272 insertions(+), 8 deletions(-) create mode 100644 src/CollaborationModeConfig.ts diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index 2ea875b5..19649b79 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -40,6 +40,8 @@ import type { } from "./app-server/v2"; import packageJson from "../package.json"; import type {AuthenticationStatusResponse} from "./AcpExtensions"; +import {createCodexCollaborationMode} from "./CollaborationModeConfig"; +import type {ModeKind} from "./app-server/ModeKind"; /** * Well-known provider id for the client-configurable custom LLM gateway. @@ -84,7 +86,10 @@ export class CodexAcpClient { async initialize(request: acp.InitializeRequest): Promise { await this.codexClient.initialize({ - capabilities: null, + capabilities: { + experimentalApi: true, + requestAttestation: false, + }, clientInfo: { name: request.clientInfo?.name ?? this.defaultClientInfo.name, version: request.clientInfo?.version ?? this.defaultClientInfo.version, @@ -679,6 +684,13 @@ export class CodexAcpClient { }, onTurnStarted); } + async setCollaborationMode(sessionId: string, mode: ModeKind, currentModelId: string): Promise { + await this.codexClient.threadSettingsUpdate({ + threadId: sessionId, + collaborationMode: createCodexCollaborationMode(mode, currentModelId), + }); + } + resolveTurnInterrupted(params: { threadId: string, turnId: string }): void { this.codexClient.resolveTurnInterrupted(params.threadId, params.turnId); } diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index adc4e079..76f8441d 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -20,6 +20,13 @@ import type { import type {RateLimitsMap} from "./RateLimitsMap"; import {ModelId} from "./ModelId"; import {AgentMode, MODE_CONFIG_ID} from "./AgentMode"; +import { + COLLABORATION_MODE_CONFIG_ID, + createCollaborationModeConfigOption, + DEFAULT_COLLABORATION_MODE, + parseCollaborationMode, +} from "./CollaborationModeConfig"; +import type {ModeKind} from "./app-server/ModeKind"; import { createModelConfigOption, createReasoningEffortConfigOption, @@ -88,6 +95,7 @@ export interface SessionState { supportedReasoningEfforts: Array, supportedInputModalities: Array, agentMode: AgentMode, + collaborationMode: ModeKind, currentTurnId: string | null; lastTokenUsage: TokenCount | null; totalTokenUsage: TokenCount | null; @@ -405,6 +413,7 @@ export class CodexAcpServer { supportedReasoningEfforts: currentModel?.supportedReasoningEfforts ?? [], supportedInputModalities: currentModel?.inputModalities ?? ["text", "image"], agentMode: AgentMode.getInitialAgentMode(), + collaborationMode: DEFAULT_COLLABORATION_MODE, currentTurnId: null, lastTokenUsage: null, totalTokenUsage: null, @@ -690,6 +699,14 @@ 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); + + return { + configOptions: this.createSessionConfigOptions(sessionState), + }; + } + + private async applySessionConfigOption(sessionState: SessionState, params: acp.SetSessionConfigOptionRequest): Promise { switch (params.configId) { case FAST_MODE_CONFIG_ID: this.applyFastModeChange(sessionState, params); @@ -697,6 +714,9 @@ export class CodexAcpServer { case MODE_CONFIG_ID: this.applyModeChange(sessionState, this.stringConfigValue(params)); break; + case COLLABORATION_MODE_CONFIG_ID: + await this.applyCollaborationModeChange(sessionState, this.stringConfigValue(params)); + break; case MODEL_CONFIG_ID: this.applyModelChange(sessionState, this.stringConfigValue(params)); break; @@ -706,10 +726,6 @@ export class CodexAcpServer { default: throw RequestError.invalidParams(); } - - return { - configOptions: this.createSessionConfigOptions(sessionState), - }; } private applyFastModeChange(sessionState: SessionState, params: acp.SetSessionConfigOptionRequest): void { @@ -739,6 +755,15 @@ export class CodexAcpServer { sessionState.agentMode = newMode; } + private async applyCollaborationModeChange(sessionState: SessionState, value: string): Promise { + const mode = parseCollaborationMode(value); + if (mode === null) { + throw RequestError.invalidParams(); + } + await this.codexAcpClient.setCollaborationMode(sessionState.sessionId, mode, sessionState.currentModelId); + sessionState.collaborationMode = mode; + } + private applyModelChange(sessionState: SessionState, value: string): void { const model = sessionState.availableModels.find(m => m.id === value); if (!model) { @@ -817,6 +842,7 @@ export class CodexAcpServer { const currentModelId = ModelId.fromString(sessionState.currentModelId); const configOptions = [ sessionState.agentMode.toConfigOption(), + createCollaborationModeConfigOption(sessionState.collaborationMode), createModelConfigOption(sessionState.availableModels, currentModelId.model), ]; if (sessionState.supportedReasoningEfforts.length > 0) { @@ -936,6 +962,7 @@ export class CodexAcpServer { supportedReasoningEfforts: currentModel?.supportedReasoningEfforts ?? [], supportedInputModalities: currentModel?.inputModalities ?? ["text", "image"], agentMode: AgentMode.getInitialAgentMode(), + collaborationMode: DEFAULT_COLLABORATION_MODE, currentTurnId: null, lastTokenUsage: null, totalTokenUsage: null, @@ -1549,6 +1576,18 @@ export class CodexAcpServer { sessionState.currentTurnId = turnId; pendingTurnStart?.resolve(turnId); }, + setConfigOption: async (configId, value) => { + await this.applySessionConfigOption(sessionState, { + sessionId: sessionState.sessionId, + configId, + value, + }); + const session = new ACPSessionConnection(this.connection, sessionState.sessionId); + await session.update({ + sessionUpdate: "config_option_update", + configOptions: this.createSessionConfigOptions(sessionState), + }); + }, }); void commandPromise.catch((err) => { if (this.activePrompts.get(params.sessionId) !== activePrompt) { diff --git a/src/CodexAppServerClient.ts b/src/CodexAppServerClient.ts index 11d61a6e..4846a018 100644 --- a/src/CodexAppServerClient.ts +++ b/src/CodexAppServerClient.ts @@ -513,6 +513,10 @@ export class CodexAppServerClient { return await this.sendRequest({ method: "thread/resume", params: params }); } + async threadSettingsUpdate(params: ExperimentalThreadSettingsUpdateParams): Promise { + await this.connection.sendRequest("thread/settings/update", params); + } + async threadList(params: ThreadListParams): Promise { return await this.sendRequest({ method: "thread/list", params: params }); } @@ -947,6 +951,18 @@ type DistributiveOmit = T extends any ? Omit : never; +export interface ExperimentalThreadSettingsUpdateParams { + threadId: string; + collaborationMode: { + mode: "default" | "plan"; + settings: { + model: string; + reasoning_effort: string | null; + developer_instructions: string | null; + }; + }; +} + type McpServerStartupSnapshot = { status: McpServerStartupState; error: string | null; diff --git a/src/CodexCommands.ts b/src/CodexCommands.ts index 19df71e6..2a89d696 100644 --- a/src/CodexCommands.ts +++ b/src/CodexCommands.ts @@ -8,6 +8,11 @@ import type {RateLimitsMap} from "./RateLimitsMap"; import type {TokenCount} from "./TokenCount"; import {logger} from "./Logger"; import {createAgentTextMessageChunk} from "./ContentChunks"; +import { + COLLABORATION_MODE_CONFIG_ID, + DEFAULT_COLLABORATION_MODE, + PLAN_COLLABORATION_MODE, +} from "./CollaborationModeConfig"; type ParsedSlashCommand = { name: string; @@ -21,6 +26,7 @@ export type CommandHandleResult = export type CommandHandleOptions = { onTurnStartPending?: () => void; onTurnStarted?: (turnId: string, threadId: string) => void; + setConfigOption?: (configId: string, value: string) => Promise; }; export type LogoutHandler = () => void | Promise; @@ -94,6 +100,20 @@ export class CodexCommands { */ private getBuiltinCommands(): AvailableCommand[] { return [ + { + name: "plan", + description: "Turn plan mode on.", + input: null, + _meta: { + commandAction: { + kind: "setConfigOption", + configId: COLLABORATION_MODE_CONFIG_ID, + value: PLAN_COLLABORATION_MODE, + resetValue: DEFAULT_COLLABORATION_MODE, + presentation: "state", + }, + }, + }, { name: "mcp", description: "List configured Model Context Protocol (MCP) tools.", @@ -173,6 +193,14 @@ export class CodexCommands { const sessionId = sessionState.sessionId; switch (commandName) { + case "plan": { + if (command.rest.length > 0) { + await this.sendCommandUsageMessage(commandName, "no arguments", sessionId); + return { handled: true }; + } + await options.setConfigOption?.(COLLABORATION_MODE_CONFIG_ID, PLAN_COLLABORATION_MODE); + return { handled: options.setConfigOption !== undefined }; + } case "compact": { await this.runWithProcessCheck(() => this.codexAcpClient.runCompact(sessionId)); return { handled: true }; diff --git a/src/CollaborationModeConfig.ts b/src/CollaborationModeConfig.ts new file mode 100644 index 00000000..b1e651e8 --- /dev/null +++ b/src/CollaborationModeConfig.ts @@ -0,0 +1,41 @@ +import type * as acp from "@agentclientprotocol/sdk"; +import type {ReasoningEffort} from "./app-server"; +import type {ModeKind} from "./app-server/ModeKind"; +import {ModelId} from "./ModelId"; + +export const COLLABORATION_MODE_CONFIG_ID = "collaboration_mode"; +export const DEFAULT_COLLABORATION_MODE: ModeKind = "default"; +export const PLAN_COLLABORATION_MODE: ModeKind = "plan"; + +export function createCollaborationModeConfigOption(currentValue: ModeKind): acp.SessionConfigOption { + return { + id: COLLABORATION_MODE_CONFIG_ID, + name: "Collaboration mode", + description: "How Codex collaborates for subsequent turns", + category: "collaboration_mode", + type: "select", + currentValue, + options: [ + {value: DEFAULT_COLLABORATION_MODE, name: "Default"}, + {value: PLAN_COLLABORATION_MODE, name: "Plan", description: "Plan before making changes"}, + ], + }; +} + +export function parseCollaborationMode(value: unknown): ModeKind | null { + if (value === DEFAULT_COLLABORATION_MODE) return DEFAULT_COLLABORATION_MODE; + if (value === PLAN_COLLABORATION_MODE) return PLAN_COLLABORATION_MODE; + return null; +} + +export function createCodexCollaborationMode(mode: ModeKind, currentModelId: string) { + const modelId = ModelId.fromString(currentModelId); + return { + mode, + settings: { + model: modelId.model, + reasoning_effort: modelId.effort as ReasoningEffort | null, + developer_instructions: null, + }, + }; +} diff --git a/src/__tests__/CodexACPAgent/data/available-commands-build-in.json b/src/__tests__/CodexACPAgent/data/available-commands-build-in.json index 26e78cbb..acd60934 100644 --- a/src/__tests__/CodexACPAgent/data/available-commands-build-in.json +++ b/src/__tests__/CodexACPAgent/data/available-commands-build-in.json @@ -6,6 +6,20 @@ "update": { "sessionUpdate": "available_commands_update", "availableCommands": [ + { + "name": "plan", + "description": "Turn plan mode on.", + "input": null, + "_meta": { + "commandAction": { + "kind": "setConfigOption", + "configId": "collaboration_mode", + "value": "plan", + "resetValue": "default", + "presentation": "state" + } + } + }, { "name": "mcp", "description": "List configured Model Context Protocol (MCP) tools.", diff --git a/src/__tests__/CodexACPAgent/data/available-commands-skills.json b/src/__tests__/CodexACPAgent/data/available-commands-skills.json index 59987072..d1f2b461 100644 --- a/src/__tests__/CodexACPAgent/data/available-commands-skills.json +++ b/src/__tests__/CodexACPAgent/data/available-commands-skills.json @@ -6,6 +6,20 @@ "update": { "sessionUpdate": "available_commands_update", "availableCommands": [ + { + "name": "plan", + "description": "Turn plan mode on.", + "input": null, + "_meta": { + "commandAction": { + "kind": "setConfigOption", + "configId": "collaboration_mode", + "value": "plan", + "resetValue": "default", + "presentation": "state" + } + } + }, { "name": "mcp", "description": "List configured Model Context Protocol (MCP) tools.", diff --git a/src/__tests__/CodexACPAgent/data/load-session-history.json b/src/__tests__/CodexACPAgent/data/load-session-history.json index bc5b3f96..ce818acd 100644 --- a/src/__tests__/CodexACPAgent/data/load-session-history.json +++ b/src/__tests__/CodexACPAgent/data/load-session-history.json @@ -6,6 +6,20 @@ "update": { "sessionUpdate": "available_commands_update", "availableCommands": [ + { + "name": "plan", + "description": "Turn plan mode on.", + "input": null, + "_meta": { + "commandAction": { + "kind": "setConfigOption", + "configId": "collaboration_mode", + "value": "plan", + "resetValue": "default", + "presentation": "state" + } + } + }, { "name": "mcp", "description": "List configured Model Context Protocol (MCP) tools.", diff --git a/src/__tests__/CodexACPAgent/data/load-session-response-item-history-fallback.json b/src/__tests__/CodexACPAgent/data/load-session-response-item-history-fallback.json index e960c459..26dc6f5e 100644 --- a/src/__tests__/CodexACPAgent/data/load-session-response-item-history-fallback.json +++ b/src/__tests__/CodexACPAgent/data/load-session-response-item-history-fallback.json @@ -6,6 +6,20 @@ "update": { "sessionUpdate": "available_commands_update", "availableCommands": [ + { + "name": "plan", + "description": "Turn plan mode on.", + "input": null, + "_meta": { + "commandAction": { + "kind": "setConfigOption", + "configId": "collaboration_mode", + "value": "plan", + "resetValue": "default", + "presentation": "state" + } + } + }, { "name": "mcp", "description": "List configured Model Context Protocol (MCP) tools.", diff --git a/src/__tests__/CodexACPAgent/initialize.test.ts b/src/__tests__/CodexACPAgent/initialize.test.ts index 79b46f59..3180835b 100644 --- a/src/__tests__/CodexACPAgent/initialize.test.ts +++ b/src/__tests__/CodexACPAgent/initialize.test.ts @@ -85,6 +85,22 @@ describe('CodexACPAgent - initialize', () => { ])); }); + it('enables experimental thread settings without requesting attestation', async () => { + await agent.initialize({ + protocolVersion: acp.PROTOCOL_VERSION, + clientCapabilities: { + elicitation: { form: {}, url: {} }, + }, + }); + + expect(mockCodexConnection.sendRequest).toHaveBeenCalledWith("initialize", expect.objectContaining({ + capabilities: { + experimentalApi: true, + requestAttestation: false, + }, + })); + }); + it('should advertise API key auth with the legacy metadata method', () => { expect(getCodexAuthMethods()).toEqual(expect.arrayContaining([ expect.objectContaining({ diff --git a/src/__tests__/CodexACPAgent/session-config-options.test.ts b/src/__tests__/CodexACPAgent/session-config-options.test.ts index 35913f4a..d40a269a 100644 --- a/src/__tests__/CodexACPAgent/session-config-options.test.ts +++ b/src/__tests__/CodexACPAgent/session-config-options.test.ts @@ -7,6 +7,10 @@ import { } from "../../ModelConfigOption"; import type {Model, ReasoningEffortOption} from "../../app-server/v2"; import {LEGACY_SET_SESSION_MODEL_METHOD} from "../../AcpExtensions"; +import { + COLLABORATION_MODE_CONFIG_ID, + PLAN_COLLABORATION_MODE, +} from "../../CollaborationModeConfig"; const lowEffort: ReasoningEffortOption = {reasoningEffort: "low", description: "Fast"}; const mediumEffort: ReasoningEffortOption = {reasoningEffort: "medium", description: "Balanced"}; @@ -46,7 +50,7 @@ async function createSession(currentModelId: string, availableModels: Array { @@ -55,7 +59,7 @@ describe("Session config options", () => { const {response} = await createSession("fast-model[medium]", [fast, slow]); const ids = response.configOptions?.map(o => o.id); - expect(ids).toEqual([MODE_CONFIG_ID, MODEL_CONFIG_ID, REASONING_EFFORT_CONFIG_ID, "fast-mode"]); + expect(ids).toEqual([MODE_CONFIG_ID, COLLABORATION_MODE_CONFIG_ID, MODEL_CONFIG_ID, REASONING_EFFORT_CONFIG_ID, "fast-mode"]); const modelOption = response.configOptions?.find(o => o.id === MODEL_CONFIG_ID); expect(modelOption).toMatchObject({ @@ -96,7 +100,7 @@ describe("Session config options", () => { const {codexAcpAgent, response} = await createSession("custom-model[high]", [fast, slow]); const ids = response.configOptions?.map(o => o.id); - expect(ids).toEqual([MODE_CONFIG_ID, MODEL_CONFIG_ID]); + expect(ids).toEqual([MODE_CONFIG_ID, COLLABORATION_MODE_CONFIG_ID, MODEL_CONFIG_ID]); const modelOption = response.configOptions?.find(o => o.id === MODEL_CONFIG_ID); expect(modelOption).toMatchObject({ @@ -149,6 +153,56 @@ describe("Session config options", () => { expect((modeOption as any).currentValue).toBe(AgentMode.ReadOnly.id); }); + it("changes collaboration mode without starting a model turn", async () => { + const {fast} = buildModels(); + const {codexAcpAgent, codexAcpClient} = await createSession("fast-model[medium]", [fast]); + const update = vi.spyOn((codexAcpClient as any).codexClient, "threadSettingsUpdate").mockResolvedValue(undefined); + + const result = await codexAcpAgent.setSessionConfigOption({ + sessionId: "session-id", + configId: COLLABORATION_MODE_CONFIG_ID, + value: PLAN_COLLABORATION_MODE, + }); + + expect(update).toHaveBeenCalledWith(expect.objectContaining({ + threadId: "session-id", + collaborationMode: expect.objectContaining({mode: "plan"}), + })); + expect(codexAcpAgent.getSessionState("session-id").collaborationMode).toBe("plan"); + expect(result.configOptions?.find(o => o.id === COLLABORATION_MODE_CONFIG_ID)).toMatchObject({currentValue: "plan"}); + }); + + it("handles /plan as a config option shortcut without starting a model turn", async () => { + const {fast} = buildModels(); + const {fixture, codexAcpAgent, codexAcpClient} = await createSession("fast-model[medium]", [fast]); + const update = vi.spyOn((codexAcpClient as any).codexClient, "threadSettingsUpdate").mockResolvedValue(undefined); + const turnStart = vi.spyOn(fixture.getCodexAppServerClient(), "turnStart"); + + const response = await codexAcpAgent.prompt({ + sessionId: "session-id", + prompt: [{type: "text", text: "/plan"}], + }); + + expect(response.stopReason).toBe("end_turn"); + expect(turnStart).not.toHaveBeenCalled(); + expect(update).toHaveBeenCalledWith(expect.objectContaining({ + threadId: "session-id", + collaborationMode: expect.objectContaining({mode: "plan"}), + })); + expect(codexAcpAgent.getSessionState("session-id").collaborationMode).toBe("plan"); + expect(fixture.getAcpConnectionEvents([])).toContainEqual(expect.objectContaining({ + method: "sessionUpdate", + args: [expect.objectContaining({ + update: expect.objectContaining({ + sessionUpdate: "config_option_update", + configOptions: expect.arrayContaining([ + expect.objectContaining({id: COLLABORATION_MODE_CONFIG_ID, currentValue: "plan"}), + ]), + }), + })], + })); + }); + it("changes the model and keeps the current reasoning effort when supported", async () => { const {fast, slow} = buildModels(); const {codexAcpAgent} = await createSession("fast-model[medium]", [fast, slow]); diff --git a/src/__tests__/acp-test-utils.ts b/src/__tests__/acp-test-utils.ts index 13505155..dc4ad85d 100644 --- a/src/__tests__/acp-test-utils.ts +++ b/src/__tests__/acp-test-utils.ts @@ -11,6 +11,7 @@ import path from "node:path"; import fs from "node:fs"; import os from "node:os"; import {AgentMode} from "../AgentMode"; +import {DEFAULT_COLLABORATION_MODE} from "../CollaborationModeConfig"; import {expect, vi} from "vitest"; import type {Model, ReasoningEffortOption} from "../app-server/v2"; @@ -383,6 +384,7 @@ export function createTestSessionState(overrides?: Partial): Sessi supportedReasoningEfforts: [], supportedInputModalities: ["text", "image"], agentMode: AgentMode.DEFAULT_AGENT_MODE, + collaborationMode: DEFAULT_COLLABORATION_MODE, fastModeEnabled: false, currentModelSupportsFast: false, terminalOutputMode: "terminal_output_delta", From cb28f3eb6a6255f01d62c14b24769bda1e4ab2cb Mon Sep 17 00:00:00 2001 From: Nikita Ashikhmin Date: Tue, 14 Jul 2026 00:59:17 +0400 Subject: [PATCH 2/5] Advertise goal composer action --- src/CodexCommands.ts | 10 ++++++++-- .../data/available-commands-build-in.json | 8 +++++++- .../CodexACPAgent/data/available-commands-skills.json | 8 +++++++- .../CodexACPAgent/data/load-session-history.json | 8 +++++++- .../load-session-response-item-history-fallback.json | 8 +++++++- 5 files changed, 36 insertions(+), 6 deletions(-) diff --git a/src/CodexCommands.ts b/src/CodexCommands.ts index 2a89d696..64784480 100644 --- a/src/CodexCommands.ts +++ b/src/CodexCommands.ts @@ -151,8 +151,14 @@ export class CodexCommands { }, { name: "goal", - description: "Set, pause, resume, or clear a task goal.", - input: { hint: "[|clear|pause|resume]" } + description: "Set a goal to keep pursuing.", + input: { hint: "[|clear|pause|resume]" }, + _meta: { + commandAction: { + kind: "prefixPrompt", + presentation: "state", + }, + }, }, { name: "logout", diff --git a/src/__tests__/CodexACPAgent/data/available-commands-build-in.json b/src/__tests__/CodexACPAgent/data/available-commands-build-in.json index acd60934..d734fe73 100644 --- a/src/__tests__/CodexACPAgent/data/available-commands-build-in.json +++ b/src/__tests__/CodexACPAgent/data/available-commands-build-in.json @@ -63,9 +63,15 @@ }, { "name": "goal", - "description": "Set, pause, resume, or clear a task goal.", + "description": "Set a goal to keep pursuing.", "input": { "hint": "[|clear|pause|resume]" + }, + "_meta": { + "commandAction": { + "kind": "prefixPrompt", + "presentation": "state" + } } }, { diff --git a/src/__tests__/CodexACPAgent/data/available-commands-skills.json b/src/__tests__/CodexACPAgent/data/available-commands-skills.json index d1f2b461..d6c15414 100644 --- a/src/__tests__/CodexACPAgent/data/available-commands-skills.json +++ b/src/__tests__/CodexACPAgent/data/available-commands-skills.json @@ -63,9 +63,15 @@ }, { "name": "goal", - "description": "Set, pause, resume, or clear a task goal.", + "description": "Set a goal to keep pursuing.", "input": { "hint": "[|clear|pause|resume]" + }, + "_meta": { + "commandAction": { + "kind": "prefixPrompt", + "presentation": "state" + } } }, { diff --git a/src/__tests__/CodexACPAgent/data/load-session-history.json b/src/__tests__/CodexACPAgent/data/load-session-history.json index ce818acd..a60bd293 100644 --- a/src/__tests__/CodexACPAgent/data/load-session-history.json +++ b/src/__tests__/CodexACPAgent/data/load-session-history.json @@ -63,9 +63,15 @@ }, { "name": "goal", - "description": "Set, pause, resume, or clear a task goal.", + "description": "Set a goal to keep pursuing.", "input": { "hint": "[|clear|pause|resume]" + }, + "_meta": { + "commandAction": { + "kind": "prefixPrompt", + "presentation": "state" + } } }, { diff --git a/src/__tests__/CodexACPAgent/data/load-session-response-item-history-fallback.json b/src/__tests__/CodexACPAgent/data/load-session-response-item-history-fallback.json index 26dc6f5e..fc9d526a 100644 --- a/src/__tests__/CodexACPAgent/data/load-session-response-item-history-fallback.json +++ b/src/__tests__/CodexACPAgent/data/load-session-response-item-history-fallback.json @@ -63,9 +63,15 @@ }, { "name": "goal", - "description": "Set, pause, resume, or clear a task goal.", + "description": "Set a goal to keep pursuing.", "input": { "hint": "[|clear|pause|resume]" + }, + "_meta": { + "commandAction": { + "kind": "prefixPrompt", + "presentation": "state" + } } }, { From cd13da3a33c933941fa786eaf80d04a20bd25372 Mon Sep 17 00:00:00 2001 From: Nikita Ashikhmin Date: Tue, 14 Jul 2026 01:44:07 +0400 Subject: [PATCH 3/5] Expose goal state and controls --- src/AcpExtensions.ts | 15 +++++- src/CodexAcpClient.ts | 6 +++ src/CodexAcpServer.ts | 46 +++++++++++++++++++ src/CodexAppServerClient.ts | 6 +++ src/CodexEventHandler.ts | 3 ++ .../CodexACPAgent/CodexAcpClient.test.ts | 29 ++++++++++-- .../data/load-session-history.json | 22 +++++++++ ...ession-response-item-history-fallback.json | 16 +++++++ .../data/thread-goal-updated-multiline.json | 4 +- .../data/thread-goal-updated.json | 4 +- .../CodexACPAgent/load-session.test.ts | 14 +++++- .../CodexACPAgent/thread-goal-events.test.ts | 4 ++ src/index.ts | 8 +++- 13 files changed, 169 insertions(+), 8 deletions(-) diff --git a/src/AcpExtensions.ts b/src/AcpExtensions.ts index 2a6b359e..490dec12 100644 --- a/src/AcpExtensions.ts +++ b/src/AcpExtensions.ts @@ -7,6 +7,7 @@ import type { } from "@agentclientprotocol/sdk"; export const LEGACY_SET_SESSION_MODEL_METHOD = "session/set_model"; +export const GOAL_CONTROL_METHOD = "session/goal_control"; export type LegacySessionModel = { modelId: string; @@ -42,11 +43,13 @@ export type ExtMethodRequest = AuthenticationStatusRequest | AuthenticationLogoutRequest | LegacySetSessionModelExtRequest + | GoalControlExtRequest export function isExtMethodRequest(request: { method: string, params: Record }): request is ExtMethodRequest { return request.method === "authentication/status" || request.method === "authentication/logout" - || request.method === LEGACY_SET_SESSION_MODEL_METHOD; + || request.method === LEGACY_SET_SESSION_MODEL_METHOD + || request.method === GOAL_CONTROL_METHOD; } export type AuthenticationStatusRequest = { method: "authentication/status", params: {} } @@ -60,6 +63,16 @@ export type LegacySetSessionModelExtRequest = { params: LegacySetSessionModelRequest; } +export type GoalControlRequest = { + sessionId: SessionId; + action: "pause" | "clear"; +} + +export type GoalControlExtRequest = { + method: typeof GOAL_CONTROL_METHOD; + params: GoalControlRequest; +} + export async function legacySetSessionModel( connection: Pick, params: LegacySetSessionModelRequest, diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index 19649b79..22d5afe8 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -33,6 +33,7 @@ import type { SkillsListResponse, SandboxPolicy, Thread, + ThreadGoal, ThreadGoalStatus, ThreadSourceKind, TurnCompletedNotification, @@ -422,6 +423,11 @@ export class CodexAcpClient { await this.codexClient.runCompact({threadId: sessionId}); } + async getGoal(sessionId: string): Promise { + const response = await this.codexClient.threadGoalGet({threadId: sessionId}); + return response?.goal ?? null; + } + async setGoal( sessionId: string, objective: string, diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index 76f8441d..2dd99380 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -48,6 +48,7 @@ import { type LegacySessionModelState, type LegacySetSessionModelRequest, type LegacySetSessionModelResponse, + GOAL_CONTROL_METHOD, isExtMethodRequest, LEGACY_SET_SESSION_MODEL_METHOD, } from "./AcpExtensions"; @@ -86,6 +87,8 @@ export interface ThreadGoalSnapshot { objective: string; status: ThreadGoalStatus; tokenBudget: number | null; + timeUsedSeconds: number; + controlMethod: typeof GOAL_CONTROL_METHOD; } export interface SessionState { @@ -255,6 +258,18 @@ export class CodexAcpServer { } case LEGACY_SET_SESSION_MODEL_METHOD: return await this.unstable_setSessionModel(this.parseLegacySetSessionModelParams(methodRequest.params)); + case GOAL_CONTROL_METHOD: { + const sessionState = this.sessions.get(methodRequest.params.sessionId); + if (!sessionState) { + throw RequestError.invalidParams(undefined, `Unknown session: ${methodRequest.params.sessionId}`); + } + if (methodRequest.params.action === "pause") { + await this.runWithProcessCheck(() => this.codexAcpClient.setGoalStatus(sessionState.sessionId, "paused")); + } else { + await this.runWithProcessCheck(() => this.codexAcpClient.clearGoal(sessionState.sessionId)); + } + return {}; + } } } @@ -443,6 +458,9 @@ export class CodexAcpServer { } this.publishAvailableCommandsAsync(sessionState); + if ("sessionId" in request) { + this.publishCurrentGoalAsync(sessionState); + } const sessionModelState: LegacySessionModelState = this.createModelState(models, currentModelId); const sessionModeState: SessionModeState = sessionState.agentMode.toSessionModeState(); @@ -879,6 +897,33 @@ export class CodexAcpServer { void this.availableCommands.publish(sessionState); } + private publishCurrentGoalAsync(sessionState: SessionState): void { + void this.publishCurrentGoal(sessionState).catch((err) => { + logger.error(`Failed to publish current goal for session ${sessionState.sessionId}`, err); + }); + } + + private async publishCurrentGoal(sessionState: SessionState): Promise { + const goal = await this.runWithProcessCheck(() => this.codexAcpClient.getGoal(sessionState.sessionId)); + const snapshot: ThreadGoalSnapshot | null = goal === null ? null : { + objective: goal.objective.trim(), + status: goal.status, + tokenBudget: goal.tokenBudget, + timeUsedSeconds: goal.timeUsedSeconds, + controlMethod: GOAL_CONTROL_METHOD, + }; + sessionState.currentGoal = snapshot; + const session = new ACPSessionConnection(this.connection, sessionState.sessionId); + await session.update({ + sessionUpdate: "session_info_update", + _meta: { + codex: { + goal: snapshot, + }, + }, + }); + } + private findCurrentModel(models: Model[], currentModelId: string): Model | undefined { const modelId = ModelId.fromString(currentModelId); return models.find(m => m.id === modelId.model); @@ -992,6 +1037,7 @@ export class CodexAcpServer { } await this.availableCommands.publish(sessionState); + await this.publishCurrentGoal(sessionState); const sessionModelState: LegacySessionModelState = this.createModelState(models, currentModelId); const sessionModeState: SessionModeState = sessionState.agentMode.toSessionModeState(); diff --git a/src/CodexAppServerClient.ts b/src/CodexAppServerClient.ts index 4846a018..9b0c9870 100644 --- a/src/CodexAppServerClient.ts +++ b/src/CodexAppServerClient.ts @@ -37,6 +37,8 @@ import type { ThreadGoalClearedNotification, ThreadGoalClearParams, ThreadGoalClearResponse, + ThreadGoalGetParams, + ThreadGoalGetResponse, ThreadGoalSetParams, ThreadGoalSetResponse, ThreadLoadedListParams, @@ -545,6 +547,10 @@ export class CodexAppServerClient { return await this.sendRequest({ method: "thread/goal/set", params: params }); } + async threadGoalGet(params: ThreadGoalGetParams): Promise { + return await this.sendRequest({ method: "thread/goal/get", params: params }); + } + async threadGoalClear(params: ThreadGoalClearParams): Promise { return await this.sendRequest({ method: "thread/goal/clear", params: params }); } diff --git a/src/CodexEventHandler.ts b/src/CodexEventHandler.ts index ea1de4a8..02710c10 100644 --- a/src/CodexEventHandler.ts +++ b/src/CodexEventHandler.ts @@ -4,6 +4,7 @@ import type { ServerNotification } from "./app-server"; import type {SessionState, ThreadGoalSnapshot} from "./CodexAcpServer"; +import {GOAL_CONTROL_METHOD} from "./AcpExtensions"; import {type PlanEntry, RequestError} from "@agentclientprotocol/sdk"; import {ACPSessionConnection, type AcpClientConnection, type UpdateSessionEvent} from "./ACPSessionConnection"; import type { @@ -283,6 +284,8 @@ export class CodexEventHandler { objective: event.goal.objective.trim(), status: event.goal.status, tokenBudget: event.goal.tokenBudget, + timeUsedSeconds: event.goal.timeUsedSeconds, + controlMethod: GOAL_CONTROL_METHOD, }; } diff --git a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts index 255d4ab2..a6ed92d6 100644 --- a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts +++ b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts @@ -16,6 +16,7 @@ import {AgentMode} from "../../AgentMode"; import type {Model, ReviewStartResponse, ThreadGoal, TurnCompletedNotification, TurnStartParams} from "../../app-server/v2"; import type {RateLimitsMap} from "../../RateLimitsMap"; import {ModelId} from "../../ModelId"; +import {GOAL_CONTROL_METHOD} from "../../AcpExtensions"; describe('ACP server test', { timeout: 40_000 }, () => { @@ -1667,9 +1668,11 @@ describe('ACP server test', { timeout: 40_000 }, () => { _meta: { codex: { goal: { - objective: "Ship the migration and keep tests green", - status: "active", - tokenBudget: null, + objective: "Ship the migration and keep tests green", + status: "active", + tokenBudget: null, + timeUsedSeconds: 0, + controlMethod: "session/goal_control", }, }, }, @@ -2388,6 +2391,26 @@ describe('ACP server test', { timeout: 40_000 }, () => { await expect(promptPromise).resolves.toMatchObject({stopReason: "cancelled"}); }); + it('controls an active goal through the out-of-band session extension', async () => { + const { mockFixture, sessionState } = setupPromptFixture(); + // @ts-expect-error - registering local session state for the extension request path + mockFixture.getCodexAcpAgent().sessions.set("session-id", sessionState); + const setStatusSpy = vi.spyOn(mockFixture.getCodexAcpClient(), "setGoalStatus").mockResolvedValue(undefined); + const clearGoalSpy = vi.spyOn(mockFixture.getCodexAcpClient(), "clearGoal").mockResolvedValue(undefined); + + await expect(mockFixture.getCodexAcpAgent().extMethod(GOAL_CONTROL_METHOD, { + sessionId: "session-id", + action: "pause", + })).resolves.toEqual({}); + await expect(mockFixture.getCodexAcpAgent().extMethod(GOAL_CONTROL_METHOD, { + sessionId: "session-id", + action: "clear", + })).resolves.toEqual({}); + + expect(setStatusSpy).toHaveBeenCalledWith("session-id", "paused"); + expect(clearGoalSpy).toHaveBeenCalledWith("session-id"); + }); + it('suppresses the first routed goal notification after cancellation marks the turn stale', async () => { const { mockFixture } = setupPromptFixture(); const codexAppServerClient = mockFixture.getCodexAppServerClient(); diff --git a/src/__tests__/CodexACPAgent/data/load-session-history.json b/src/__tests__/CodexACPAgent/data/load-session-history.json index a60bd293..91e3d5a1 100644 --- a/src/__tests__/CodexACPAgent/data/load-session-history.json +++ b/src/__tests__/CodexACPAgent/data/load-session-history.json @@ -84,6 +84,28 @@ } ] } +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "session-1", + "update": { + "sessionUpdate": "session_info_update", + "_meta": { + "codex": { + "goal": { + "objective": "Keep the restored migration green", + "status": "paused", + "tokenBudget": null, + "timeUsedSeconds": 46, + "controlMethod": "session/goal_control" + } + } + } + } + } + ] +} { "method": "sessionUpdate", "args": [ diff --git a/src/__tests__/CodexACPAgent/data/load-session-response-item-history-fallback.json b/src/__tests__/CodexACPAgent/data/load-session-response-item-history-fallback.json index fc9d526a..b314d869 100644 --- a/src/__tests__/CodexACPAgent/data/load-session-response-item-history-fallback.json +++ b/src/__tests__/CodexACPAgent/data/load-session-response-item-history-fallback.json @@ -84,6 +84,22 @@ } ] } +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "session-legacy", + "update": { + "sessionUpdate": "session_info_update", + "_meta": { + "codex": { + "goal": null + } + } + } + } + ] +} { "method": "sessionUpdate", "args": [ diff --git a/src/__tests__/CodexACPAgent/data/thread-goal-updated-multiline.json b/src/__tests__/CodexACPAgent/data/thread-goal-updated-multiline.json index c524584c..a96f3b03 100644 --- a/src/__tests__/CodexACPAgent/data/thread-goal-updated-multiline.json +++ b/src/__tests__/CodexACPAgent/data/thread-goal-updated-multiline.json @@ -10,7 +10,9 @@ "goal": { "objective": "First task\nSecond task", "status": "budgetLimited", - "tokenBudget": 1000 + "tokenBudget": 1000, + "timeUsedSeconds": 30, + "controlMethod": "session/goal_control" } } } diff --git a/src/__tests__/CodexACPAgent/data/thread-goal-updated.json b/src/__tests__/CodexACPAgent/data/thread-goal-updated.json index bc14e9cb..7fdbbb38 100644 --- a/src/__tests__/CodexACPAgent/data/thread-goal-updated.json +++ b/src/__tests__/CodexACPAgent/data/thread-goal-updated.json @@ -10,7 +10,9 @@ "goal": { "objective": "Ship the goal update", "status": "active", - "tokenBudget": null + "tokenBudget": null, + "timeUsedSeconds": 12, + "controlMethod": "session/goal_control" } } } diff --git a/src/__tests__/CodexACPAgent/load-session.test.ts b/src/__tests__/CodexACPAgent/load-session.test.ts index f8aea520..1b2b81e0 100644 --- a/src/__tests__/CodexACPAgent/load-session.test.ts +++ b/src/__tests__/CodexACPAgent/load-session.test.ts @@ -4,7 +4,7 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createCodexMockTestFixture, createTestModel } from "../acp-test-utils"; -import type { Model, Thread } from "../../app-server/v2"; +import type { Model, Thread, ThreadGoal } from "../../app-server/v2"; describe("CodexACPAgent - loadSession", () => { it("should replay history during loadSession", async () => { @@ -190,6 +190,17 @@ describe("CodexACPAgent - loadSession", () => { codexAppServerClient.threadRead = vi.fn().mockResolvedValue({ thread: thread, }); + const goal: ThreadGoal = { + threadId: thread.id, + objective: "Keep the restored migration green", + status: "paused", + tokenBudget: null, + tokensUsed: 42, + timeUsedSeconds: 46, + createdAt: 1710000000, + updatedAt: 1710000046, + }; + codexAppServerClient.threadGoalGet = vi.fn().mockResolvedValue({ goal }); await codexAcpAgent.initialize({ protocolVersion: 1 }); @@ -204,6 +215,7 @@ describe("CodexACPAgent - loadSession", () => { threadId: thread.id, includeTurns: true, }); + expect(codexAppServerClient.threadGoalGet).toHaveBeenCalledWith({ threadId: thread.id }); await expect(fixture.getAcpConnectionDump([])).toMatchFileSnapshot( "data/load-session-history.json" ); diff --git a/src/__tests__/CodexACPAgent/thread-goal-events.test.ts b/src/__tests__/CodexACPAgent/thread-goal-events.test.ts index fc690994..4f94ffc2 100644 --- a/src/__tests__/CodexACPAgent/thread-goal-events.test.ts +++ b/src/__tests__/CodexACPAgent/thread-goal-events.test.ts @@ -131,6 +131,8 @@ describe("CodexEventHandler - thread goal events", () => { objective: "Ship the goal update", status: "active", tokenBudget: null, + timeUsedSeconds: 12, + controlMethod: "session/goal_control", }, }, }, @@ -187,6 +189,8 @@ describe("CodexEventHandler - thread goal events", () => { objective: "tell me a joke", status: "complete", tokenBudget: null, + timeUsedSeconds: 12, + controlMethod: "session/goal_control", }, }, }, diff --git a/src/index.ts b/src/index.ts index ca0b3d37..b2edd2c9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,7 +12,7 @@ import packageJson from "../package.json"; import {logger} from "./Logger"; import {runLoginCommand} from "./login"; import {runCodexCli} from "./CodexCli"; -import {LEGACY_SET_SESSION_MODEL_METHOD} from "./AcpExtensions"; +import {GOAL_CONTROL_METHOD, LEGACY_SET_SESSION_MODEL_METHOD} from "./AcpExtensions"; const emptyExtensionParamsParser = z.preprocess( (params) => params ?? {}, @@ -24,6 +24,11 @@ const legacySetSessionModelParamsParser = z.object({ modelId: z.string(), }).passthrough(); +const goalControlParamsParser = z.object({ + sessionId: z.string(), + action: z.enum(["pause", "clear"]), +}).passthrough(); + if (process.argv.includes("--version")) { console.log(`${packageJson.name} ${packageJson.version}`); process.exit(0); @@ -132,5 +137,6 @@ function startAcpServer() { .onRequest("authentication/status", emptyExtensionParamsParser, (ctx) => getAgent().extMethod("authentication/status", ctx.params)) .onRequest("authentication/logout", emptyExtensionParamsParser, (ctx) => getAgent().extMethod("authentication/logout", ctx.params)) .onRequest(LEGACY_SET_SESSION_MODEL_METHOD, legacySetSessionModelParamsParser, (ctx) => getAgent().extMethod(LEGACY_SET_SESSION_MODEL_METHOD, ctx.params)) + .onRequest(GOAL_CONTROL_METHOD, goalControlParamsParser, (ctx) => getAgent().extMethod(GOAL_CONTROL_METHOD, ctx.params)) .connect(acpJsonStream); } From 77cc1b06ee7d7e57ae04abad8b3a669a3d730412 Mon Sep 17 00:00:00 2001 From: Nikita Ashikhmin Date: Tue, 14 Jul 2026 14:53:56 +0400 Subject: [PATCH 4/5] Harden plan and goal session state Restore collaboration mode when sessions are loaded and publish authoritative goal snapshots without allowing stale reads to overwrite live updates. Keep goal control responses typed and preserve stable goal identity for UI replay. --- src/CodexAcpClient.ts | 17 ++- src/CodexAcpServer.ts | 93 ++++++++---- src/CodexAppServerClient.ts | 11 ++ src/CodexCommands.ts | 5 +- src/CodexEventHandler.ts | 31 +--- src/ThreadGoalSnapshot.ts | 34 +++++ .../CodexACPAgent/CodexAcpClient.test.ts | 133 +++++++++++++++++- .../data/load-session-history.json | 1 + .../data/thread-goal-updated-multiline.json | 1 + .../data/thread-goal-updated.json | 1 + .../CodexACPAgent/fast-mode-config.test.ts | 1 + .../CodexACPAgent/list-sessions.test.ts | 1 + .../CodexACPAgent/model-filtering.test.ts | 1 + .../CodexACPAgent/new-session-logout.test.ts | 2 + .../CodexACPAgent/session-close.test.ts | 2 + .../session-config-options.test.ts | 32 ++++- .../CodexACPAgent/session-delete.test.ts | 1 + .../CodexACPAgent/thread-goal-events.test.ts | 42 ++++++ src/__tests__/acp-test-utils.ts | 1 + 19 files changed, 348 insertions(+), 62 deletions(-) create mode 100644 src/ThreadGoalSnapshot.ts diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index 22d5afe8..b7664d36 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -336,6 +336,7 @@ export class CodexAcpClient { sessionId: request.sessionId, currentModelId: currentModelId, models: codexModels, + collaborationMode: this.getCollaborationMode(response.thread.id), modelProvider: response.modelProvider, currentServiceTier: response.serviceTier as ServiceTier ?? null, additionalDirectories, @@ -363,6 +364,7 @@ export class CodexAcpClient { sessionId: request.sessionId, currentModelId: currentModelId, models: codexModels, + collaborationMode: this.getCollaborationMode(response.thread.id), modelProvider: response.modelProvider, currentServiceTier: response.serviceTier as ServiceTier ?? null, thread: historyResponse.thread, @@ -389,6 +391,7 @@ export class CodexAcpClient { sessionId: response.thread.id, currentModelId: currentModelId, models: codexModels, + collaborationMode: this.getCollaborationMode(response.thread.id), modelProvider: response.modelProvider, currentServiceTier: response.serviceTier as ServiceTier ?? null, additionalDirectories, @@ -440,11 +443,18 @@ export class CodexAcpClient { }, onTurnStarted); } - async setGoalStatus(sessionId: string, status: ThreadGoalStatus): Promise { + async setGoalStatus(sessionId: string, status: ThreadGoalStatus): Promise { + let updatedGoal: ThreadGoal | null = null; await this.codexClient.runGoalSet({ threadId: sessionId, status, + }, undefined, undefined, (goal) => { + updatedGoal = goal; }); + if (updatedGoal === null) { + throw new Error(`Goal update for session ${sessionId} returned no goal`); + } + return updatedGoal; } async resumeGoal( @@ -697,6 +707,10 @@ export class CodexAcpClient { }); } + private getCollaborationMode(sessionId: string): ModeKind { + return this.codexClient.getThreadSettings(sessionId)?.collaborationMode.mode ?? "default"; + } + resolveTurnInterrupted(params: { threadId: string, turnId: string }): void { this.codexClient.resolveTurnInterrupted(params.threadId, params.turnId); } @@ -863,6 +877,7 @@ export type SessionMetadata = { sessionId: string, currentModelId: string, models: Model[], + collaborationMode: ModeKind, modelProvider?: string | null, currentServiceTier?: ServiceTier | null, additionalDirectories: string[], diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index 2dd99380..eedaf6a3 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -13,7 +13,6 @@ import type { Model, ReasoningEffortOption, Thread, - ThreadGoalStatus, ThreadItem, UserInput } from "./app-server/v2"; @@ -23,7 +22,6 @@ import {AgentMode, MODE_CONFIG_ID} from "./AgentMode"; import { COLLABORATION_MODE_CONFIG_ID, createCollaborationModeConfigOption, - DEFAULT_COLLABORATION_MODE, parseCollaborationMode, } from "./CollaborationModeConfig"; import type {ModeKind} from "./app-server/ModeKind"; @@ -82,14 +80,11 @@ import { createAgentTextThoughtChunk, createUserMessageChunk, } from "./ContentChunks"; - -export interface ThreadGoalSnapshot { - objective: string; - status: ThreadGoalStatus; - tokenBudget: number | null; - timeUsedSeconds: number; - controlMethod: typeof GOAL_CONTROL_METHOD; -} +import { + sameThreadGoalSnapshot, + type ThreadGoalSnapshot, + toThreadGoalSnapshot, +} from "./ThreadGoalSnapshot"; export interface SessionState { sessionId: string, @@ -114,6 +109,7 @@ export interface SessionState { sessionMcpServers?: Array; terminalOutputMode: TerminalOutputMode; currentGoal?: ThreadGoalSnapshot | null; + goalRevision: number; sessionTitle: string | null; sessionTitleSource: "unset" | "fallback" | "explicit" | "unknown"; } @@ -263,10 +259,17 @@ export class CodexAcpServer { if (!sessionState) { throw RequestError.invalidParams(undefined, `Unknown session: ${methodRequest.params.sessionId}`); } + const sessionGeneration = this.getSessionGeneration(sessionState.sessionId); if (methodRequest.params.action === "pause") { - await this.runWithProcessCheck(() => this.codexAcpClient.setGoalStatus(sessionState.sessionId, "paused")); - } else { + const goal = await this.runWithProcessCheck(() => this.codexAcpClient.setGoalStatus(sessionState.sessionId, "paused")); + if (this.goalPublishIsCurrent(sessionState, sessionGeneration)) { + await this.publishGoalSnapshot(sessionState, toThreadGoalSnapshot(goal), false); + } + } else if (methodRequest.params.action === "clear") { await this.runWithProcessCheck(() => this.codexAcpClient.clearGoal(sessionState.sessionId)); + if (this.goalPublishIsCurrent(sessionState, sessionGeneration)) { + await this.publishGoalSnapshot(sessionState, null, false); + } } return {}; } @@ -428,7 +431,7 @@ export class CodexAcpServer { supportedReasoningEfforts: currentModel?.supportedReasoningEfforts ?? [], supportedInputModalities: currentModel?.inputModalities ?? ["text", "image"], agentMode: AgentMode.getInitialAgentMode(), - collaborationMode: DEFAULT_COLLABORATION_MODE, + collaborationMode: sessionMetadata.collaborationMode, currentTurnId: null, lastTokenUsage: null, totalTokenUsage: null, @@ -443,6 +446,7 @@ export class CodexAcpServer { currentModelSupportsFast: currentModelSupportsFast, sessionMcpServers: sessionMcpServers, terminalOutputMode: this.terminalOutputMode, + goalRevision: 0, sessionTitle: null, sessionTitleSource: "sessionId" in request ? "unknown" : "unset", }; @@ -459,7 +463,7 @@ export class CodexAcpServer { this.publishAvailableCommandsAsync(sessionState); if ("sessionId" in request) { - this.publishCurrentGoalAsync(sessionState); + this.publishCurrentGoalAsync(sessionState, sessionGeneration); } const sessionModelState: LegacySessionModelState = this.createModelState(models, currentModelId); const sessionModeState: SessionModeState = sessionState.agentMode.toSessionModeState(); @@ -897,21 +901,55 @@ export class CodexAcpServer { void this.availableCommands.publish(sessionState); } - private publishCurrentGoalAsync(sessionState: SessionState): void { - void this.publishCurrentGoal(sessionState).catch((err) => { + private publishCurrentGoalAsync(sessionState: SessionState, sessionGeneration: number): void { + void this.publishCurrentGoalBestEffort(sessionState, sessionGeneration, true); + } + + private async publishCurrentGoalBestEffort( + sessionState: SessionState, + sessionGeneration: number, + force: boolean, + ): Promise { + try { + await this.publishCurrentGoal(sessionState, sessionGeneration, force); + } catch (err) { logger.error(`Failed to publish current goal for session ${sessionState.sessionId}`, err); - }); + } } - private async publishCurrentGoal(sessionState: SessionState): Promise { + private async publishCurrentGoal( + sessionState: SessionState, + sessionGeneration: number, + force: boolean, + ): Promise { + const requestRevision = ++sessionState.goalRevision; const goal = await this.runWithProcessCheck(() => this.codexAcpClient.getGoal(sessionState.sessionId)); - const snapshot: ThreadGoalSnapshot | null = goal === null ? null : { - objective: goal.objective.trim(), - status: goal.status, - tokenBudget: goal.tokenBudget, - timeUsedSeconds: goal.timeUsedSeconds, - controlMethod: GOAL_CONTROL_METHOD, - }; + const snapshot = goal === null ? null : toThreadGoalSnapshot(goal); + if (!this.goalPublishIsCurrent(sessionState, sessionGeneration) + || sessionState.goalRevision !== requestRevision) { + return; + } + await this.publishGoalSnapshot(sessionState, snapshot, force, false); + } + + private goalPublishIsCurrent(sessionState: SessionState, sessionGeneration: number): boolean { + return this.sessions.get(sessionState.sessionId) === sessionState + && this.getSessionGeneration(sessionState.sessionId) === sessionGeneration + && !this.sessionIsClosing(sessionState.sessionId); + } + + private async publishGoalSnapshot( + sessionState: SessionState, + snapshot: ThreadGoalSnapshot | null, + force: boolean, + incrementRevision = true, + ): Promise { + if (incrementRevision) { + sessionState.goalRevision += 1; + } + if (!force && sameThreadGoalSnapshot(sessionState.currentGoal, snapshot)) { + return; + } sessionState.currentGoal = snapshot; const session = new ACPSessionConnection(this.connection, sessionState.sessionId); await session.update({ @@ -1007,7 +1045,7 @@ export class CodexAcpServer { supportedReasoningEfforts: currentModel?.supportedReasoningEfforts ?? [], supportedInputModalities: currentModel?.inputModalities ?? ["text", "image"], agentMode: AgentMode.getInitialAgentMode(), - collaborationMode: DEFAULT_COLLABORATION_MODE, + collaborationMode: sessionMetadata.collaborationMode, currentTurnId: null, lastTokenUsage: null, totalTokenUsage: null, @@ -1022,6 +1060,7 @@ export class CodexAcpServer { currentModelSupportsFast: currentModelSupportsFast, sessionMcpServers: sessionMcpServers, terminalOutputMode: this.terminalOutputMode, + goalRevision: 0, sessionTitle: null, sessionTitleSource: "unset", }; @@ -1037,7 +1076,7 @@ export class CodexAcpServer { } await this.availableCommands.publish(sessionState); - await this.publishCurrentGoal(sessionState); + await this.publishCurrentGoalBestEffort(sessionState, requestedSessionGeneration, true); const sessionModelState: LegacySessionModelState = this.createModelState(models, currentModelId); const sessionModeState: SessionModeState = sessionState.agentMode.toSessionModeState(); diff --git a/src/CodexAppServerClient.ts b/src/CodexAppServerClient.ts index 9b0c9870..aa88155d 100644 --- a/src/CodexAppServerClient.ts +++ b/src/CodexAppServerClient.ts @@ -49,6 +49,7 @@ import type { ThreadReadResponse, ThreadResumeParams, ThreadResumeResponse, + ThreadSettings, ThreadStartParams, ThreadStartResponse, ThreadUnsubscribeParams, @@ -141,6 +142,7 @@ export class CodexAppServerClient { private readonly threadStatusCaptures = new Map void>>(); private readonly threadGoalUpdateCaptures = new Map void>>(); private readonly threadGoalClearedCaptures = new Map void>>(); + private readonly threadSettings = new Map(); private readonly staleTurnIds = new Map>(); constructor(connection: MessageConnection) { @@ -171,6 +173,9 @@ export class CodexAppServerClient { if (isThreadGoalClearedNotification(serverNotification)) { this.recordThreadGoalCleared(serverNotification.params); } + if (serverNotification.method === "thread/settings/updated") { + this.threadSettings.set(serverNotification.params.threadId, serverNotification.params.threadSettings); + } const routing = extractTurnRouting(serverNotification); if (this.handleStaleTurnNotification(serverNotification, routing)) { return; @@ -312,6 +317,7 @@ export class CodexAppServerClient { params: ThreadGoalSetParams, onTurnStarted?: (turnId: string) => void, runtimeEffectsGraceMs = GOAL_RUNTIME_EFFECTS_GRACE_MS, + onGoalSet?: (goal: ThreadGoal) => void, ): Promise { let goalTurnId: string | null = null; const capturedCompletions: Array = []; @@ -363,6 +369,7 @@ export class CodexAppServerClient { try { const goalSetResponse = await this.threadGoalSet(params); expectedGoal = goalSetResponse.goal; + onGoalSet?.(expectedGoal); if (capturedGoalUpdates.some(event => goalsMatch(event.goal, expectedGoal!))) { goalUpdateHandled = true; resolveGoalUpdateHandled(); @@ -515,6 +522,10 @@ export class CodexAppServerClient { return await this.sendRequest({ method: "thread/resume", params: params }); } + getThreadSettings(threadId: string): ThreadSettings | undefined { + return this.threadSettings.get(threadId); + } + async threadSettingsUpdate(params: ExperimentalThreadSettingsUpdateParams): Promise { await this.connection.sendRequest("thread/settings/update", params); } diff --git a/src/CodexCommands.ts b/src/CodexCommands.ts index 64784480..e6a837ed 100644 --- a/src/CodexCommands.ts +++ b/src/CodexCommands.ts @@ -204,7 +204,10 @@ export class CodexCommands { await this.sendCommandUsageMessage(commandName, "no arguments", sessionId); return { handled: true }; } - await options.setConfigOption?.(COLLABORATION_MODE_CONFIG_ID, PLAN_COLLABORATION_MODE); + const mode = sessionState.collaborationMode === PLAN_COLLABORATION_MODE + ? DEFAULT_COLLABORATION_MODE + : PLAN_COLLABORATION_MODE; + await options.setConfigOption?.(COLLABORATION_MODE_CONFIG_ID, mode); return { handled: options.setConfigOption !== undefined }; } case "compact": { diff --git a/src/CodexEventHandler.ts b/src/CodexEventHandler.ts index 02710c10..22e8248b 100644 --- a/src/CodexEventHandler.ts +++ b/src/CodexEventHandler.ts @@ -3,8 +3,7 @@ import type { FuzzyFileSearchSessionUpdatedNotification, ServerNotification } from "./app-server"; -import type {SessionState, ThreadGoalSnapshot} from "./CodexAcpServer"; -import {GOAL_CONTROL_METHOD} from "./AcpExtensions"; +import type {SessionState} from "./CodexAcpServer"; import {type PlanEntry, RequestError} from "@agentclientprotocol/sdk"; import {ACPSessionConnection, type AcpClientConnection, type UpdateSessionEvent} from "./ACPSessionConnection"; import type { @@ -63,6 +62,7 @@ import { createAgentTextMessageChunk, createAgentTextThoughtChunk, } from "./ContentChunks"; +import {sameThreadGoalSnapshot, toThreadGoalSnapshot} from "./ThreadGoalSnapshot"; export { stripShellPrefix }; @@ -257,8 +257,9 @@ export class CodexEventHandler { } private createThreadGoalUpdatedEvent(event: ThreadGoalUpdatedNotification): UpdateSessionEvent | null { - const goalSnapshot = this.createThreadGoalSnapshot(event); - if (this.sameThreadGoalSnapshot(this.sessionState.currentGoal, goalSnapshot)) { + this.sessionState.goalRevision += 1; + const goalSnapshot = toThreadGoalSnapshot(event.goal); + if (sameThreadGoalSnapshot(this.sessionState.currentGoal, goalSnapshot)) { return null; } this.sessionState.currentGoal = goalSnapshot; @@ -269,6 +270,7 @@ export class CodexEventHandler { } private createThreadGoalClearedEvent(_event: ThreadGoalClearedNotification): UpdateSessionEvent | null { + this.sessionState.goalRevision += 1; if (this.sessionState.currentGoal === null) { return null; } @@ -279,27 +281,6 @@ export class CodexEventHandler { }); } - private createThreadGoalSnapshot(event: ThreadGoalUpdatedNotification): ThreadGoalSnapshot { - return { - objective: event.goal.objective.trim(), - status: event.goal.status, - tokenBudget: event.goal.tokenBudget, - timeUsedSeconds: event.goal.timeUsedSeconds, - controlMethod: GOAL_CONTROL_METHOD, - }; - } - - private sameThreadGoalSnapshot( - left: ThreadGoalSnapshot | null | undefined, - right: ThreadGoalSnapshot - ): boolean { - return left !== null - && left !== undefined - && left.objective === right.objective - && left.status === right.status - && left.tokenBudget === right.tokenBudget; - } - private createReasoningDeltaEvent( event: ReasoningSummaryTextDeltaNotification | ReasoningTextDeltaNotification ): UpdateSessionEvent { diff --git a/src/ThreadGoalSnapshot.ts b/src/ThreadGoalSnapshot.ts new file mode 100644 index 00000000..bb950af8 --- /dev/null +++ b/src/ThreadGoalSnapshot.ts @@ -0,0 +1,34 @@ +import {GOAL_CONTROL_METHOD} from "./AcpExtensions"; +import type {ThreadGoal} from "./app-server/v2"; + +export interface ThreadGoalSnapshot { + objective: string; + status: ThreadGoal["status"]; + tokenBudget: number | null; + timeUsedSeconds: number; + createdAt: number; + controlMethod: typeof GOAL_CONTROL_METHOD; +} + +export function toThreadGoalSnapshot(goal: ThreadGoal): ThreadGoalSnapshot { + return { + objective: goal.objective.trim(), + status: goal.status, + tokenBudget: goal.tokenBudget, + timeUsedSeconds: goal.timeUsedSeconds, + createdAt: goal.createdAt, + controlMethod: GOAL_CONTROL_METHOD, + }; +} + +export function sameThreadGoalSnapshot( + left: ThreadGoalSnapshot | null | undefined, + right: ThreadGoalSnapshot | null, +): boolean { + if (left === undefined) return false; + if (left === null || right === null) return left === right; + return left.objective === right.objective + && left.status === right.status + && left.tokenBudget === right.tokenBudget + && left.createdAt === right.createdAt; +} diff --git a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts index a6ed92d6..f6db0728 100644 --- a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts +++ b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts @@ -454,6 +454,61 @@ describe('ACP server test', { timeout: 40_000 }, () => { }); }); + it('restores collaboration mode for resumed and loaded sessions', async () => { + const mockFixture = createCodexMockTestFixture(); + const codexAcpAgent = mockFixture.getCodexAcpAgent(); + const codexAcpClient = mockFixture.getCodexAcpClient(); + const codexAppServerClient = mockFixture.getCodexAppServerClient(); + + vi.spyOn(codexAcpClient, "authRequired").mockResolvedValue(false); + vi.spyOn(codexAcpClient, "getAccount").mockResolvedValue({account: null, requiresOpenaiAuth: false}); + vi.spyOn(codexAppServerClient, "skillsExtraRootsSet").mockResolvedValue(undefined); + vi.spyOn(codexAppServerClient, "listSkills").mockResolvedValue({data: []}); + vi.spyOn(codexAppServerClient, "threadResume").mockImplementation(async ({threadId}) => { + mockFixture.sendServerNotification({ + method: "thread/settings/updated", + params: { + threadId, + threadSettings: { + collaborationMode: { + mode: "plan", + settings: {}, + }, + }, + }, + }); + return { + thread: {id: threadId}, + model: "gpt-5", + modelProvider: "openai", + reasoningEffort: "medium", + serviceTier: null, + } as any; + }); + vi.spyOn(codexAppServerClient, "threadRead").mockImplementation(async ({threadId}) => ({ + thread: {id: threadId, turns: []}, + } as any)); + vi.spyOn(codexAppServerClient, "listModels").mockResolvedValue({ + data: [createTestModel({id: "gpt-5"})], + nextCursor: null, + }); + + const resumed = await codexAcpAgent.resumeSession({ + sessionId: "resume-id", + cwd: "/workspace", + }); + const loaded = await codexAcpAgent.loadSession({ + sessionId: "load-id", + cwd: "/workspace", + mcpServers: [], + }); + + expect(codexAcpAgent.getSessionState("resume-id").collaborationMode).toBe("plan"); + expect(codexAcpAgent.getSessionState("load-id").collaborationMode).toBe("plan"); + expect(resumed.configOptions?.find(option => option.id === "collaboration_mode")).toMatchObject({currentValue: "plan"}); + expect(loaded.configOptions?.find(option => option.id === "collaboration_mode")).toMatchObject({currentValue: "plan"}); + }); + it('uses configured model provider when resuming sessions without an explicit provider', async () => { const mockFixture = createCodexMockTestFixture(); const codexAcpClient = mockFixture.getCodexAcpClient(); @@ -1497,9 +1552,12 @@ describe('ACP server test', { timeout: 40_000 }, () => { it('handles goal slash commands through Codex app server', async () => { const { mockFixture, turnStartSpy } = setupPromptFixture(); const goalRunSpy = vi.spyOn(mockFixture.getCodexAppServerClient(), "runGoalSet") - .mockResolvedValue({ - threadId: "session-id", - turn: createTurn("goal-turn-id", "completed"), + .mockImplementation(async (_params, _onTurnStarted, _runtimeEffectsGraceMs, onGoalSet) => { + onGoalSet?.(createThreadGoal()); + return { + threadId: "session-id", + turn: createTurn("goal-turn-id", "completed"), + }; }); const goalClearSpy = vi.spyOn(mockFixture.getCodexAppServerClient(), "runGoalClear") .mockResolvedValue(undefined); @@ -1529,7 +1587,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { expect(goalRunSpy).toHaveBeenNthCalledWith(2, { threadId: "session-id", status: "paused", - }); + }, undefined, undefined, expect.any(Function)); expect(goalRunSpy).toHaveBeenNthCalledWith(3, { threadId: "session-id", status: "active", @@ -1672,6 +1730,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { status: "active", tokenBudget: null, timeUsedSeconds: 0, + createdAt: 1710000000, controlMethod: "session/goal_control", }, }, @@ -2395,8 +2454,11 @@ describe('ACP server test', { timeout: 40_000 }, () => { const { mockFixture, sessionState } = setupPromptFixture(); // @ts-expect-error - registering local session state for the extension request path mockFixture.getCodexAcpAgent().sessions.set("session-id", sessionState); - const setStatusSpy = vi.spyOn(mockFixture.getCodexAcpClient(), "setGoalStatus").mockResolvedValue(undefined); + const pausedGoal = createThreadGoal({status: "paused", timeUsedSeconds: 12}); + const setStatusSpy = vi.spyOn(mockFixture.getCodexAcpClient(), "setGoalStatus").mockResolvedValue(pausedGoal); const clearGoalSpy = vi.spyOn(mockFixture.getCodexAcpClient(), "clearGoal").mockResolvedValue(undefined); + const getGoalSpy = vi.spyOn(mockFixture.getCodexAcpClient(), "getGoal"); + mockFixture.clearAcpConnectionDump(); await expect(mockFixture.getCodexAcpAgent().extMethod(GOAL_CONTROL_METHOD, { sessionId: "session-id", @@ -2409,6 +2471,63 @@ describe('ACP server test', { timeout: 40_000 }, () => { expect(setStatusSpy).toHaveBeenCalledWith("session-id", "paused"); expect(clearGoalSpy).toHaveBeenCalledWith("session-id"); + expect(getGoalSpy).not.toHaveBeenCalled(); + const goalUpdates = mockFixture.getAcpConnectionEvents([]).filter(event => + event.method === "sessionUpdate" + && "args" in event + && event.args[0]?.update?.sessionUpdate === "session_info_update" + ); + expect(goalUpdates).toEqual(expect.arrayContaining([ + expect.objectContaining({ + args: [expect.objectContaining({ + update: expect.objectContaining({ + _meta: {codex: {goal: expect.objectContaining({status: "paused"})}}, + }), + })], + }), + expect.objectContaining({ + args: [expect.objectContaining({ + update: expect.objectContaining({ + _meta: {codex: {goal: null}}, + }), + })], + }), + ])); + }); + + it('ignores an older goal refresh that completes after a newer refresh', async () => { + const mockFixture = createCodexMockTestFixture(); + const codexAcpAgent = mockFixture.getCodexAcpAgent(); + const sessionState = createTestSessionState({sessionId: "session-id"}); + // @ts-expect-error - registering local session state for the refresh race + codexAcpAgent.sessions.set("session-id", sessionState); + const staleGoal = createThreadGoal({objective: "stale", createdAt: 100}); + const currentGoal = createThreadGoal({objective: "current", createdAt: 200}); + const staleResponse = deferred(); + const currentResponse = deferred(); + const getGoal = vi.spyOn(mockFixture.getCodexAcpClient(), "getGoal") + .mockReturnValueOnce(staleResponse.promise) + .mockReturnValueOnce(currentResponse.promise); + + // @ts-expect-error - exercising the private refresh interleaving directly + const stalePublish = codexAcpAgent.publishCurrentGoal(sessionState, 0, true); + await vi.waitFor(() => expect(getGoal).toHaveBeenCalledTimes(1)); + // @ts-expect-error - exercising the private refresh interleaving directly + const currentPublish = codexAcpAgent.publishCurrentGoal(sessionState, 0, true); + currentResponse.resolve(currentGoal); + await currentPublish; + staleResponse.resolve(staleGoal); + await stalePublish; + + expect(sessionState.currentGoal).toMatchObject({objective: "current", createdAt: 200}); + const goalUpdates = mockFixture.getAcpConnectionEvents([]).filter(event => + event.method === "sessionUpdate" + && event.args[0]?.update?.sessionUpdate === "session_info_update" + ); + expect(goalUpdates).toHaveLength(1); + expect(goalUpdates[0]?.args[0]?.update?._meta).toEqual({ + codex: {goal: expect.objectContaining({objective: "current", createdAt: 200})}, + }); }); it('suppresses the first routed goal notification after cancellation marks the turn stale', async () => { @@ -2701,12 +2820,14 @@ describe('ACP server test', { timeout: 40_000 }, () => { sessionId: "session-1", currentModelId, models: [model], + collaborationMode: "default", additionalDirectories: [], }) .mockResolvedValueOnce({ sessionId: "session-2", currentModelId, models: [model], + collaborationMode: "default", additionalDirectories: [], }); const logoutSpy = vi.spyOn(codexAcpClient, "logout").mockResolvedValue(); @@ -2765,6 +2886,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { sessionId: "openai-session", currentModelId, models: [model], + collaborationMode: "default", modelProvider: "openai", additionalDirectories: [], }); @@ -2817,6 +2939,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { sessionId: "custom-provider-session", currentModelId, models: [model], + collaborationMode: "default", additionalDirectories: [], }); const logoutSpy = vi.spyOn(codexAcpClient, "logout").mockResolvedValue(); diff --git a/src/__tests__/CodexACPAgent/data/load-session-history.json b/src/__tests__/CodexACPAgent/data/load-session-history.json index 91e3d5a1..d74b1828 100644 --- a/src/__tests__/CodexACPAgent/data/load-session-history.json +++ b/src/__tests__/CodexACPAgent/data/load-session-history.json @@ -98,6 +98,7 @@ "status": "paused", "tokenBudget": null, "timeUsedSeconds": 46, + "createdAt": 1710000000, "controlMethod": "session/goal_control" } } diff --git a/src/__tests__/CodexACPAgent/data/thread-goal-updated-multiline.json b/src/__tests__/CodexACPAgent/data/thread-goal-updated-multiline.json index a96f3b03..b427fb74 100644 --- a/src/__tests__/CodexACPAgent/data/thread-goal-updated-multiline.json +++ b/src/__tests__/CodexACPAgent/data/thread-goal-updated-multiline.json @@ -12,6 +12,7 @@ "status": "budgetLimited", "tokenBudget": 1000, "timeUsedSeconds": 30, + "createdAt": 1710000000, "controlMethod": "session/goal_control" } } diff --git a/src/__tests__/CodexACPAgent/data/thread-goal-updated.json b/src/__tests__/CodexACPAgent/data/thread-goal-updated.json index 7fdbbb38..cf9ce92f 100644 --- a/src/__tests__/CodexACPAgent/data/thread-goal-updated.json +++ b/src/__tests__/CodexACPAgent/data/thread-goal-updated.json @@ -12,6 +12,7 @@ "status": "active", "tokenBudget": null, "timeUsedSeconds": 12, + "createdAt": 1710000000, "controlMethod": "session/goal_control" } } diff --git a/src/__tests__/CodexACPAgent/fast-mode-config.test.ts b/src/__tests__/CodexACPAgent/fast-mode-config.test.ts index 07d42444..6007d529 100644 --- a/src/__tests__/CodexACPAgent/fast-mode-config.test.ts +++ b/src/__tests__/CodexACPAgent/fast-mode-config.test.ts @@ -43,6 +43,7 @@ describe("Fast mode session config", () => { sessionId: "session-id", currentModelId: "fast-model[medium]", models: [fastModel, slowModel], + collaborationMode: "default", currentServiceTier, additionalDirectories: [], }); diff --git a/src/__tests__/CodexACPAgent/list-sessions.test.ts b/src/__tests__/CodexACPAgent/list-sessions.test.ts index 36529576..845ee0fd 100644 --- a/src/__tests__/CodexACPAgent/list-sessions.test.ts +++ b/src/__tests__/CodexACPAgent/list-sessions.test.ts @@ -168,6 +168,7 @@ describe("CodexACPAgent - list sessions", () => { defaultServiceTier: null, isDefault: true, }], + collaborationMode: "default", currentServiceTier: null, additionalDirectories: ["/repo/extra"], }); diff --git a/src/__tests__/CodexACPAgent/model-filtering.test.ts b/src/__tests__/CodexACPAgent/model-filtering.test.ts index 280410a3..f5344242 100644 --- a/src/__tests__/CodexACPAgent/model-filtering.test.ts +++ b/src/__tests__/CodexACPAgent/model-filtering.test.ts @@ -128,6 +128,7 @@ describe("Model filtering", () => { sessionId: "session-id", currentModelId: "gpt-5.2[medium]", models, + collaborationMode: "default", additionalDirectories: [], }); vi.spyOn(codexAcpClient, "getAccount").mockResolvedValue({account: null, requiresOpenaiAuth: false}); diff --git a/src/__tests__/CodexACPAgent/new-session-logout.test.ts b/src/__tests__/CodexACPAgent/new-session-logout.test.ts index c8603f13..f2f331eb 100644 --- a/src/__tests__/CodexACPAgent/new-session-logout.test.ts +++ b/src/__tests__/CodexACPAgent/new-session-logout.test.ts @@ -64,6 +64,7 @@ describe("New session logout handling", () => { sessionId: "openai-session", currentModelId, models: [model], + collaborationMode: "default", modelProvider: "openai", additionalDirectories: [], }) @@ -71,6 +72,7 @@ describe("New session logout handling", () => { sessionId: "custom-provider-session", currentModelId, models: [model], + collaborationMode: "default", modelProvider: "custom-provider", additionalDirectories: [], }) diff --git a/src/__tests__/CodexACPAgent/session-close.test.ts b/src/__tests__/CodexACPAgent/session-close.test.ts index 032abe3e..5effd4b0 100644 --- a/src/__tests__/CodexACPAgent/session-close.test.ts +++ b/src/__tests__/CodexACPAgent/session-close.test.ts @@ -457,6 +457,7 @@ async function createSession(options: { sessionId, currentModelId: "model-id[medium]", models: [model], + collaborationMode: "default", currentServiceTier: null, additionalDirectories: [], }); @@ -503,6 +504,7 @@ function createSessionMetadata(): SessionMetadata { sessionId, currentModelId: "model-id[medium]", models: [createTestModel()], + collaborationMode: "default", currentServiceTier: null, additionalDirectories: [], }; diff --git a/src/__tests__/CodexACPAgent/session-config-options.test.ts b/src/__tests__/CodexACPAgent/session-config-options.test.ts index d40a269a..ab669916 100644 --- a/src/__tests__/CodexACPAgent/session-config-options.test.ts +++ b/src/__tests__/CodexACPAgent/session-config-options.test.ts @@ -46,6 +46,7 @@ async function createSession(currentModelId: string, availableModels: Array { expect(result.configOptions?.find(o => o.id === COLLABORATION_MODE_CONFIG_ID)).toMatchObject({currentValue: "plan"}); }); - it("handles /plan as a config option shortcut without starting a model turn", async () => { + it("toggles collaboration mode with /plan without starting a model turn", async () => { const {fast} = buildModels(); const {fixture, codexAcpAgent, codexAcpClient} = await createSession("fast-model[medium]", [fast]); const update = vi.spyOn((codexAcpClient as any).codexClient, "threadSettingsUpdate").mockResolvedValue(undefined); const turnStart = vi.spyOn(fixture.getCodexAppServerClient(), "turnStart"); - const response = await codexAcpAgent.prompt({ + const enabledResponse = await codexAcpAgent.prompt({ sessionId: "session-id", prompt: [{type: "text", text: "/plan"}], }); - expect(response.stopReason).toBe("end_turn"); + expect(enabledResponse.stopReason).toBe("end_turn"); expect(turnStart).not.toHaveBeenCalled(); expect(update).toHaveBeenCalledWith(expect.objectContaining({ threadId: "session-id", collaborationMode: expect.objectContaining({mode: "plan"}), })); expect(codexAcpAgent.getSessionState("session-id").collaborationMode).toBe("plan"); + + const disabledResponse = await codexAcpAgent.prompt({ + sessionId: "session-id", + prompt: [{type: "text", text: "/plan"}], + }); + + expect(disabledResponse.stopReason).toBe("end_turn"); + expect(turnStart).not.toHaveBeenCalled(); + expect(update).toHaveBeenLastCalledWith(expect.objectContaining({ + threadId: "session-id", + collaborationMode: expect.objectContaining({mode: "default"}), + })); + expect(codexAcpAgent.getSessionState("session-id").collaborationMode).toBe("default"); expect(fixture.getAcpConnectionEvents([])).toContainEqual(expect.objectContaining({ method: "sessionUpdate", args: [expect.objectContaining({ @@ -201,6 +215,17 @@ describe("Session config options", () => { }), })], })); + expect(fixture.getAcpConnectionEvents([])).toContainEqual(expect.objectContaining({ + method: "sessionUpdate", + args: [expect.objectContaining({ + update: expect.objectContaining({ + sessionUpdate: "config_option_update", + configOptions: expect.arrayContaining([ + expect.objectContaining({id: COLLABORATION_MODE_CONFIG_ID, currentValue: "default"}), + ]), + }), + })], + })); }); it("changes the model and keeps the current reasoning effort when supported", async () => { @@ -254,6 +279,7 @@ describe("Session config options", () => { sessionId: "session-id", currentModelId: "fast-model[medium]", models: [fast], + collaborationMode: "default", additionalDirectories: [], }); await codexAcpAgent.newSession({cwd: "/test/cwd", mcpServers: []}); diff --git a/src/__tests__/CodexACPAgent/session-delete.test.ts b/src/__tests__/CodexACPAgent/session-delete.test.ts index cc8285e6..ba0207ed 100644 --- a/src/__tests__/CodexACPAgent/session-delete.test.ts +++ b/src/__tests__/CodexACPAgent/session-delete.test.ts @@ -128,6 +128,7 @@ async function createSession(): Promise<{ sessionId, currentModelId: "model-id[medium]", models: [model], + collaborationMode: "default", currentServiceTier: null, additionalDirectories: [], }); diff --git a/src/__tests__/CodexACPAgent/thread-goal-events.test.ts b/src/__tests__/CodexACPAgent/thread-goal-events.test.ts index 4f94ffc2..434a2ba2 100644 --- a/src/__tests__/CodexACPAgent/thread-goal-events.test.ts +++ b/src/__tests__/CodexACPAgent/thread-goal-events.test.ts @@ -132,6 +132,7 @@ describe("CodexEventHandler - thread goal events", () => { status: "active", tokenBudget: null, timeUsedSeconds: 12, + createdAt: 1710000000, controlMethod: "session/goal_control", }, }, @@ -139,6 +140,46 @@ describe("CodexEventHandler - thread goal events", () => { }); }); + it("should publish a replacement goal with the same contents and a different creation time", async () => { + const firstGoal: ServerNotification = { + method: "thread/goal/updated", + params: { + threadId: sessionId, + turnId: null, + goal: { + threadId: sessionId, + objective: "Ship the goal update", + status: "active", + tokenBudget: null, + tokensUsed: 0, + timeUsedSeconds: 0, + createdAt: 1710000000, + updatedAt: 1710000000, + }, + }, + }; + const replacementGoal: ServerNotification = { + ...firstGoal, + params: { + ...firstGoal.params, + goal: { + ...firstGoal.params.goal, + createdAt: 1710000100, + updatedAt: 1710000100, + }, + }, + }; + + await setupPromptAndSendNotifications(mockFixture, sessionId, createSessionState(), [firstGoal, replacementGoal]); + + const events = mockFixture.getAcpConnectionEvents([]); + expect(events).toHaveLength(2); + expect(events.map(event => event.args[0].update._meta?.codex?.goal?.createdAt)).toEqual([ + 1710000000, + 1710000100, + ]); + }); + it("should not append completed goal updates to preceding agent text", async () => { const goalCompletedNotification: ServerNotification = { method: "thread/goal/updated", @@ -190,6 +231,7 @@ describe("CodexEventHandler - thread goal events", () => { status: "complete", tokenBudget: null, timeUsedSeconds: 12, + createdAt: 1710000000, controlMethod: "session/goal_control", }, }, diff --git a/src/__tests__/acp-test-utils.ts b/src/__tests__/acp-test-utils.ts index dc4ad85d..de4ad962 100644 --- a/src/__tests__/acp-test-utils.ts +++ b/src/__tests__/acp-test-utils.ts @@ -388,6 +388,7 @@ export function createTestSessionState(overrides?: Partial): Sessi fastModeEnabled: false, currentModelSupportsFast: false, terminalOutputMode: "terminal_output_delta", + goalRevision: 0, sessionTitle: null, sessionTitleSource: "unknown", ...overrides, From 41ceacfadc854fd4eef559146065dba4a74f2983 Mon Sep 17 00:00:00 2001 From: Nikita Ashikhmin Date: Wed, 15 Jul 2026 18:45:05 +0400 Subject: [PATCH 5/5] Namespace goal control ACP extension --- src/AcpExtensions.ts | 2 +- src/__tests__/CodexACPAgent/CodexAcpClient.test.ts | 2 +- src/__tests__/CodexACPAgent/data/load-session-history.json | 2 +- .../CodexACPAgent/data/thread-goal-updated-multiline.json | 2 +- src/__tests__/CodexACPAgent/data/thread-goal-updated.json | 2 +- src/__tests__/CodexACPAgent/thread-goal-events.test.ts | 4 ++-- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/AcpExtensions.ts b/src/AcpExtensions.ts index 490dec12..5a10ea5e 100644 --- a/src/AcpExtensions.ts +++ b/src/AcpExtensions.ts @@ -7,7 +7,7 @@ import type { } from "@agentclientprotocol/sdk"; export const LEGACY_SET_SESSION_MODEL_METHOD = "session/set_model"; -export const GOAL_CONTROL_METHOD = "session/goal_control"; +export const GOAL_CONTROL_METHOD = "_codex/session/goal_control"; export type LegacySessionModel = { modelId: string; diff --git a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts index f6db0728..4399afc8 100644 --- a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts +++ b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts @@ -1731,7 +1731,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { tokenBudget: null, timeUsedSeconds: 0, createdAt: 1710000000, - controlMethod: "session/goal_control", + controlMethod: "_codex/session/goal_control", }, }, }, diff --git a/src/__tests__/CodexACPAgent/data/load-session-history.json b/src/__tests__/CodexACPAgent/data/load-session-history.json index d74b1828..106786b4 100644 --- a/src/__tests__/CodexACPAgent/data/load-session-history.json +++ b/src/__tests__/CodexACPAgent/data/load-session-history.json @@ -99,7 +99,7 @@ "tokenBudget": null, "timeUsedSeconds": 46, "createdAt": 1710000000, - "controlMethod": "session/goal_control" + "controlMethod": "_codex/session/goal_control" } } } diff --git a/src/__tests__/CodexACPAgent/data/thread-goal-updated-multiline.json b/src/__tests__/CodexACPAgent/data/thread-goal-updated-multiline.json index b427fb74..6008d56e 100644 --- a/src/__tests__/CodexACPAgent/data/thread-goal-updated-multiline.json +++ b/src/__tests__/CodexACPAgent/data/thread-goal-updated-multiline.json @@ -13,7 +13,7 @@ "tokenBudget": 1000, "timeUsedSeconds": 30, "createdAt": 1710000000, - "controlMethod": "session/goal_control" + "controlMethod": "_codex/session/goal_control" } } } diff --git a/src/__tests__/CodexACPAgent/data/thread-goal-updated.json b/src/__tests__/CodexACPAgent/data/thread-goal-updated.json index cf9ce92f..ed17b6da 100644 --- a/src/__tests__/CodexACPAgent/data/thread-goal-updated.json +++ b/src/__tests__/CodexACPAgent/data/thread-goal-updated.json @@ -13,7 +13,7 @@ "tokenBudget": null, "timeUsedSeconds": 12, "createdAt": 1710000000, - "controlMethod": "session/goal_control" + "controlMethod": "_codex/session/goal_control" } } } diff --git a/src/__tests__/CodexACPAgent/thread-goal-events.test.ts b/src/__tests__/CodexACPAgent/thread-goal-events.test.ts index 434a2ba2..bc88ac54 100644 --- a/src/__tests__/CodexACPAgent/thread-goal-events.test.ts +++ b/src/__tests__/CodexACPAgent/thread-goal-events.test.ts @@ -133,7 +133,7 @@ describe("CodexEventHandler - thread goal events", () => { tokenBudget: null, timeUsedSeconds: 12, createdAt: 1710000000, - controlMethod: "session/goal_control", + controlMethod: "_codex/session/goal_control", }, }, }, @@ -232,7 +232,7 @@ describe("CodexEventHandler - thread goal events", () => { tokenBudget: null, timeUsedSeconds: 12, createdAt: 1710000000, - controlMethod: "session/goal_control", + controlMethod: "_codex/session/goal_control", }, }, },