From 52d637a986d7bbf72d23fe8fc357076db6e8d082 Mon Sep 17 00:00:00 2001 From: nikita-ashihmin Date: Wed, 15 Jul 2026 20:21:25 +0400 Subject: [PATCH 01/25] Add plan and goal command actions (#293) * Add plan mode command action * Advertise goal composer action * Expose goal state and controls * 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. * Namespace goal control ACP extension --- src/AcpExtensions.ts | 15 +- src/CodexAcpClient.ts | 37 +++- src/CodexAcpServer.ts | 146 ++++++++++++++-- src/CodexAppServerClient.ts | 33 ++++ src/CodexCommands.ts | 41 ++++- src/CodexEventHandler.ts | 28 +-- src/CollaborationModeConfig.ts | 41 +++++ src/ThreadGoalSnapshot.ts | 34 ++++ .../CodexACPAgent/CodexAcpClient.test.ts | 160 +++++++++++++++++- .../data/available-commands-build-in.json | 22 ++- .../data/available-commands-skills.json | 22 ++- .../data/load-session-history.json | 45 ++++- ...ession-response-item-history-fallback.json | 38 ++++- .../data/thread-goal-updated-multiline.json | 5 +- .../data/thread-goal-updated.json | 5 +- .../CodexACPAgent/fast-mode-config.test.ts | 1 + .../CodexACPAgent/initialize.test.ts | 16 ++ .../CodexACPAgent/list-sessions.test.ts | 1 + .../CodexACPAgent/load-session.test.ts | 14 +- .../CodexACPAgent/model-filtering.test.ts | 1 + .../CodexACPAgent/new-session-logout.test.ts | 2 + .../CodexACPAgent/session-close.test.ts | 2 + .../session-config-options.test.ts | 86 +++++++++- .../CodexACPAgent/session-delete.test.ts | 1 + .../CodexACPAgent/thread-goal-events.test.ts | 46 +++++ src/__tests__/acp-test-utils.ts | 3 + src/index.ts | 8 +- 27 files changed, 797 insertions(+), 56 deletions(-) create mode 100644 src/CollaborationModeConfig.ts create mode 100644 src/ThreadGoalSnapshot.ts diff --git a/src/AcpExtensions.ts b/src/AcpExtensions.ts index 2a6b359e..5a10ea5e 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 = "_codex/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 2ea875b5..b7664d36 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -33,6 +33,7 @@ import type { SkillsListResponse, SandboxPolicy, Thread, + ThreadGoal, ThreadGoalStatus, ThreadSourceKind, TurnCompletedNotification, @@ -40,6 +41,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 +87,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, @@ -330,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, @@ -357,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, @@ -383,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, @@ -417,6 +426,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, @@ -429,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( @@ -679,6 +700,17 @@ export class CodexAcpClient { }, onTurnStarted); } + async setCollaborationMode(sessionId: string, mode: ModeKind, currentModelId: string): Promise { + await this.codexClient.threadSettingsUpdate({ + threadId: sessionId, + collaborationMode: createCodexCollaborationMode(mode, currentModelId), + }); + } + + 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); } @@ -845,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 adc4e079..eedaf6a3 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -13,13 +13,18 @@ import type { Model, ReasoningEffortOption, Thread, - ThreadGoalStatus, ThreadItem, UserInput } from "./app-server/v2"; import type {RateLimitsMap} from "./RateLimitsMap"; import {ModelId} from "./ModelId"; import {AgentMode, MODE_CONFIG_ID} from "./AgentMode"; +import { + COLLABORATION_MODE_CONFIG_ID, + createCollaborationModeConfigOption, + parseCollaborationMode, +} from "./CollaborationModeConfig"; +import type {ModeKind} from "./app-server/ModeKind"; import { createModelConfigOption, createReasoningEffortConfigOption, @@ -41,6 +46,7 @@ import { type LegacySessionModelState, type LegacySetSessionModelRequest, type LegacySetSessionModelResponse, + GOAL_CONTROL_METHOD, isExtMethodRequest, LEGACY_SET_SESSION_MODEL_METHOD, } from "./AcpExtensions"; @@ -74,12 +80,11 @@ import { createAgentTextThoughtChunk, createUserMessageChunk, } from "./ContentChunks"; - -export interface ThreadGoalSnapshot { - objective: string; - status: ThreadGoalStatus; - tokenBudget: number | null; -} +import { + sameThreadGoalSnapshot, + type ThreadGoalSnapshot, + toThreadGoalSnapshot, +} from "./ThreadGoalSnapshot"; export interface SessionState { sessionId: string, @@ -88,6 +93,7 @@ export interface SessionState { supportedReasoningEfforts: Array, supportedInputModalities: Array, agentMode: AgentMode, + collaborationMode: ModeKind, currentTurnId: string | null; lastTokenUsage: TokenCount | null; totalTokenUsage: TokenCount | null; @@ -103,6 +109,7 @@ export interface SessionState { sessionMcpServers?: Array; terminalOutputMode: TerminalOutputMode; currentGoal?: ThreadGoalSnapshot | null; + goalRevision: number; sessionTitle: string | null; sessionTitleSource: "unset" | "fallback" | "explicit" | "unknown"; } @@ -247,6 +254,25 @@ 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}`); + } + const sessionGeneration = this.getSessionGeneration(sessionState.sessionId); + if (methodRequest.params.action === "pause") { + 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 {}; + } } } @@ -405,6 +431,7 @@ export class CodexAcpServer { supportedReasoningEfforts: currentModel?.supportedReasoningEfforts ?? [], supportedInputModalities: currentModel?.inputModalities ?? ["text", "image"], agentMode: AgentMode.getInitialAgentMode(), + collaborationMode: sessionMetadata.collaborationMode, currentTurnId: null, lastTokenUsage: null, totalTokenUsage: null, @@ -419,6 +446,7 @@ export class CodexAcpServer { currentModelSupportsFast: currentModelSupportsFast, sessionMcpServers: sessionMcpServers, terminalOutputMode: this.terminalOutputMode, + goalRevision: 0, sessionTitle: null, sessionTitleSource: "sessionId" in request ? "unknown" : "unset", }; @@ -434,6 +462,9 @@ export class CodexAcpServer { } this.publishAvailableCommandsAsync(sessionState); + if ("sessionId" in request) { + this.publishCurrentGoalAsync(sessionState, sessionGeneration); + } const sessionModelState: LegacySessionModelState = this.createModelState(models, currentModelId); const sessionModeState: SessionModeState = sessionState.agentMode.toSessionModeState(); @@ -690,6 +721,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 +736,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 +748,6 @@ export class CodexAcpServer { default: throw RequestError.invalidParams(); } - - return { - configOptions: this.createSessionConfigOptions(sessionState), - }; } private applyFastModeChange(sessionState: SessionState, params: acp.SetSessionConfigOptionRequest): void { @@ -739,6 +777,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 +864,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) { @@ -853,6 +901,67 @@ export class CodexAcpServer { void this.availableCommands.publish(sessionState); } + 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, + sessionGeneration: number, + force: boolean, + ): Promise { + const requestRevision = ++sessionState.goalRevision; + const goal = await this.runWithProcessCheck(() => this.codexAcpClient.getGoal(sessionState.sessionId)); + 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({ + 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); @@ -936,6 +1045,7 @@ export class CodexAcpServer { supportedReasoningEfforts: currentModel?.supportedReasoningEfforts ?? [], supportedInputModalities: currentModel?.inputModalities ?? ["text", "image"], agentMode: AgentMode.getInitialAgentMode(), + collaborationMode: sessionMetadata.collaborationMode, currentTurnId: null, lastTokenUsage: null, totalTokenUsage: null, @@ -950,6 +1060,7 @@ export class CodexAcpServer { currentModelSupportsFast: currentModelSupportsFast, sessionMcpServers: sessionMcpServers, terminalOutputMode: this.terminalOutputMode, + goalRevision: 0, sessionTitle: null, sessionTitleSource: "unset", }; @@ -965,6 +1076,7 @@ export class CodexAcpServer { } await this.availableCommands.publish(sessionState); + await this.publishCurrentGoalBestEffort(sessionState, requestedSessionGeneration, true); const sessionModelState: LegacySessionModelState = this.createModelState(models, currentModelId); const sessionModeState: SessionModeState = sessionState.agentMode.toSessionModeState(); @@ -1549,6 +1661,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..aa88155d 100644 --- a/src/CodexAppServerClient.ts +++ b/src/CodexAppServerClient.ts @@ -37,6 +37,8 @@ import type { ThreadGoalClearedNotification, ThreadGoalClearParams, ThreadGoalClearResponse, + ThreadGoalGetParams, + ThreadGoalGetResponse, ThreadGoalSetParams, ThreadGoalSetResponse, ThreadLoadedListParams, @@ -47,6 +49,7 @@ import type { ThreadReadResponse, ThreadResumeParams, ThreadResumeResponse, + ThreadSettings, ThreadStartParams, ThreadStartResponse, ThreadUnsubscribeParams, @@ -139,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) { @@ -169,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; @@ -310,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 = []; @@ -361,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(); @@ -513,6 +522,14 @@ 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); + } + async threadList(params: ThreadListParams): Promise { return await this.sendRequest({ method: "thread/list", params: params }); } @@ -541,6 +558,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 }); } @@ -947,6 +968,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..e6a837ed 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.", @@ -131,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", @@ -173,6 +199,17 @@ 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 }; + } + 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": { await this.runWithProcessCheck(() => this.codexAcpClient.runCompact(sessionId)); return { handled: true }; diff --git a/src/CodexEventHandler.ts b/src/CodexEventHandler.ts index ea1de4a8..22e8248b 100644 --- a/src/CodexEventHandler.ts +++ b/src/CodexEventHandler.ts @@ -3,7 +3,7 @@ import type { FuzzyFileSearchSessionUpdatedNotification, ServerNotification } from "./app-server"; -import type {SessionState, ThreadGoalSnapshot} from "./CodexAcpServer"; +import type {SessionState} from "./CodexAcpServer"; import {type PlanEntry, RequestError} from "@agentclientprotocol/sdk"; import {ACPSessionConnection, type AcpClientConnection, type UpdateSessionEvent} from "./ACPSessionConnection"; import type { @@ -62,6 +62,7 @@ import { createAgentTextMessageChunk, createAgentTextThoughtChunk, } from "./ContentChunks"; +import {sameThreadGoalSnapshot, toThreadGoalSnapshot} from "./ThreadGoalSnapshot"; export { stripShellPrefix }; @@ -256,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; @@ -268,6 +270,7 @@ export class CodexEventHandler { } private createThreadGoalClearedEvent(_event: ThreadGoalClearedNotification): UpdateSessionEvent | null { + this.sessionState.goalRevision += 1; if (this.sessionState.currentGoal === null) { return null; } @@ -278,25 +281,6 @@ export class CodexEventHandler { }); } - private createThreadGoalSnapshot(event: ThreadGoalUpdatedNotification): ThreadGoalSnapshot { - return { - objective: event.goal.objective.trim(), - status: event.goal.status, - tokenBudget: event.goal.tokenBudget, - }; - } - - 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/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/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 255d4ab2..4399afc8 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 }, () => { @@ -453,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(); @@ -1496,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); @@ -1528,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", @@ -1667,9 +1726,12 @@ 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, + createdAt: 1710000000, + controlMethod: "_codex/session/goal_control", }, }, }, @@ -2388,6 +2450,86 @@ 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 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", + 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"); + 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 () => { const { mockFixture } = setupPromptFixture(); const codexAppServerClient = mockFixture.getCodexAppServerClient(); @@ -2678,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(); @@ -2742,6 +2886,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { sessionId: "openai-session", currentModelId, models: [model], + collaborationMode: "default", modelProvider: "openai", additionalDirectories: [], }); @@ -2794,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/available-commands-build-in.json b/src/__tests__/CodexACPAgent/data/available-commands-build-in.json index 26e78cbb..d734fe73 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.", @@ -49,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 59987072..d6c15414 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.", @@ -49,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 bc5b3f96..106786b4 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.", @@ -49,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" + } } }, { @@ -64,6 +84,29 @@ } ] } +{ + "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, + "createdAt": 1710000000, + "controlMethod": "_codex/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 e960c459..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 @@ -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.", @@ -49,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" + } } }, { @@ -64,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..6008d56e 100644 --- a/src/__tests__/CodexACPAgent/data/thread-goal-updated-multiline.json +++ b/src/__tests__/CodexACPAgent/data/thread-goal-updated-multiline.json @@ -10,7 +10,10 @@ "goal": { "objective": "First task\nSecond task", "status": "budgetLimited", - "tokenBudget": 1000 + "tokenBudget": 1000, + "timeUsedSeconds": 30, + "createdAt": 1710000000, + "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 bc14e9cb..ed17b6da 100644 --- a/src/__tests__/CodexACPAgent/data/thread-goal-updated.json +++ b/src/__tests__/CodexACPAgent/data/thread-goal-updated.json @@ -10,7 +10,10 @@ "goal": { "objective": "Ship the goal update", "status": "active", - "tokenBudget": null + "tokenBudget": null, + "timeUsedSeconds": 12, + "createdAt": 1710000000, + "controlMethod": "_codex/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/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/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/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/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 35913f4a..ab669916 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"}; @@ -42,11 +46,12 @@ async function createSession(currentModelId: string, availableModels: Array { @@ -55,7 +60,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 +101,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 +154,80 @@ 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("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 enabledResponse = await codexAcpAgent.prompt({ + sessionId: "session-id", + prompt: [{type: "text", text: "/plan"}], + }); + + 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({ + update: expect.objectContaining({ + sessionUpdate: "config_option_update", + configOptions: expect.arrayContaining([ + expect.objectContaining({id: COLLABORATION_MODE_CONFIG_ID, currentValue: "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: "default"}), + ]), + }), + })], + })); + }); + 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]); @@ -200,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 fc690994..bc88ac54 100644 --- a/src/__tests__/CodexACPAgent/thread-goal-events.test.ts +++ b/src/__tests__/CodexACPAgent/thread-goal-events.test.ts @@ -131,12 +131,55 @@ describe("CodexEventHandler - thread goal events", () => { objective: "Ship the goal update", status: "active", tokenBudget: null, + timeUsedSeconds: 12, + createdAt: 1710000000, + controlMethod: "_codex/session/goal_control", }, }, }, }); }); + 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", @@ -187,6 +230,9 @@ describe("CodexEventHandler - thread goal events", () => { objective: "tell me a joke", status: "complete", tokenBudget: null, + timeUsedSeconds: 12, + createdAt: 1710000000, + controlMethod: "_codex/session/goal_control", }, }, }, diff --git a/src/__tests__/acp-test-utils.ts b/src/__tests__/acp-test-utils.ts index 13505155..de4ad962 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,9 +384,11 @@ 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", + goalRevision: 0, sessionTitle: null, sessionTitleSource: "unknown", ...overrides, 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 b7466f5785e56306e532f998bfe3235e26ea0470 Mon Sep 17 00:00:00 2001 From: nikita-ashihmin Date: Thu, 16 Jul 2026 00:25:50 +0400 Subject: [PATCH 02/25] Expose Codex subagent activity over ACP (#304) Map subAgentActivity notifications to standard ACP tool calls with namespaced Codex metadata. Preserve collaboration model metadata and replay the same events when loading session history. --- README.md | 1 + src/CodexAcpServer.ts | 4 +- src/CodexEventHandler.ts | 11 ++- src/CodexToolCallMapper.ts | 67 +++++++++++++++++++ .../CodexACPAgent/collab-agent-events.test.ts | 26 +++++++ .../data/collab-agent-tool-call-flow.json | 26 +++++++ .../data/load-session-history.json | 29 ++++++++ .../data/subagent-activity-flow.json | 29 ++++++++ .../CodexACPAgent/load-session.test.ts | 7 ++ 9 files changed, 198 insertions(+), 2 deletions(-) create mode 100644 src/__tests__/CodexACPAgent/data/subagent-activity-flow.json diff --git a/README.md b/README.md index fe401b19..7cf19dae 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ Use [OpenAI Codex](https://github.com/openai/codex) from [Agent Client Protocol] - Model, reasoning effort, fast mode, approval, and sandbox mode configuration. - Text prompts, embedded context, images, resource links, and additional workspace directories. - Shell command, file change, permission request, MCP tool call, terminal output, reasoning, plan, web search, image generation, image view, token usage, and review events. +- Subagent launches as standard ACP tool calls, with Codex thread identity and activity details in namespaced `_meta.codex.subagent` metadata. - Client-provided MCP servers over command-based stdio config and HTTP transport. - Slash commands: `/status`, `/mcp`, `/skills`, `/review`, `/review-branch`, `/review-commit`, `/compact`, and `/logout`, as well as configured skills. diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index eedaf6a3..24a883e7 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -60,6 +60,7 @@ import { createImageGenerationUpdate, createImageViewUpdate, createMcpToolCallUpdate, + createSubAgentActivityUpdate, formatWebSearchTitle, } from "./CodexToolCallMapper"; import { @@ -1179,9 +1180,10 @@ export class CodexAcpServer { case "userMessage": return this.createUserMessageUpdates(item); case "hookPrompt": - case "subAgentActivity": case "sleep": return []; + case "subAgentActivity": + return [createSubAgentActivityUpdate(item, "completed", "tool_call")]; case "agentMessage": { const meta = createCodexMessagePhaseMeta(item.phase); return [{ diff --git a/src/CodexEventHandler.ts b/src/CodexEventHandler.ts index 22e8248b..9b83a1d6 100644 --- a/src/CodexEventHandler.ts +++ b/src/CodexEventHandler.ts @@ -51,6 +51,7 @@ import { createFuzzyFileSearchComplete, createFuzzyFileSearchStartOrUpdate, createMcpToolCallUpdate, + createSubAgentActivityUpdate, createWebSearchCompleteUpdate, createWebSearchStartUpdate, fuzzyFileSearchToolCallId, @@ -79,6 +80,7 @@ export class CodexEventHandler { private readonly terminalCommandIds = new Set(); private readonly terminalCommandOutputIds = new Set(); private readonly agentMessagePhases = new Map(); + private readonly activeSubAgentActivities = new Set(); constructor(connection: AcpClientConnection, sessionState: SessionState) { this.connection = connection; @@ -330,6 +332,8 @@ export class CodexEventHandler { case "contextCompaction": return createContextCompactionStartUpdate(event.item); case "subAgentActivity": + this.activeSubAgentActivities.add(event.item.id); + return createSubAgentActivityUpdate(event.item, "in_progress", "tool_call"); case "sleep": case "userMessage": case "hookPrompt": @@ -387,7 +391,12 @@ export class CodexEventHandler { case "contextCompaction": return createContextCompactionCompleteUpdate(event.item); //ignored types - case "subAgentActivity": + case "subAgentActivity": { + const sessionUpdate = this.activeSubAgentActivities.delete(event.item.id) + ? "tool_call_update" + : "tool_call"; + return createSubAgentActivityUpdate(event.item, "completed", sessionUpdate); + } case "sleep": case "userMessage": case "hookPrompt": diff --git a/src/CodexToolCallMapper.ts b/src/CodexToolCallMapper.ts index 20465fc2..c14612eb 100644 --- a/src/CodexToolCallMapper.ts +++ b/src/CodexToolCallMapper.ts @@ -40,6 +40,7 @@ type GuardianApprovalReviewNotification = | ItemGuardianApprovalReviewCompletedNotification; type WebSearchItem = ThreadItem & { type: "webSearch" }; type CollabAgentToolCallItem = ThreadItem & { type: "collabAgentToolCall" }; +type SubAgentActivityItem = ThreadItem & { type: "subAgentActivity" }; type CommandExecutionItem = ThreadItem & { type: "commandExecution" }; type ContextCompactionItem = ThreadItem & { type: "contextCompaction" }; type AcpToolCallEvent = Extract; @@ -412,6 +413,7 @@ export function createCollabAgentToolCallUpdate( title: item.tool, status: toAcpStatus(item.status), rawInput: createCollabAgentToolCallRawInput(item), + _meta: createCollabAgentToolCallMeta(item), }; } @@ -424,6 +426,7 @@ export function createCollabAgentToolCallCompleteUpdate( title: item.tool, status: toAcpStatus(item.status), rawInput: createCollabAgentToolCallRawInput(item), + _meta: createCollabAgentToolCallMeta(item), }; } @@ -433,10 +436,74 @@ function createCollabAgentToolCallRawInput(item: CollabAgentToolCallItem) { senderThreadId: item.senderThreadId, receiverThreadIds: item.receiverThreadIds, agentsStates: item.agentsStates, + model: item.model, + reasoningEffort: item.reasoningEffort, status: item.status, }; } +function createCollabAgentToolCallMeta(item: CollabAgentToolCallItem) { + return { + codex: { + collaboration: { + tool: item.tool, + senderThreadId: item.senderThreadId, + receiverThreadIds: item.receiverThreadIds, + }, + }, + }; +} + +export function createSubAgentActivityUpdate( + item: SubAgentActivityItem, + status: "in_progress" | "completed", + sessionUpdate: "tool_call" | "tool_call_update", +): UpdateSessionEvent { + const name = item.agentPath.split("/").filter(Boolean).at(-1) ?? "subagent"; + const title = formatSubAgentActivityTitle(item.kind, name); + const common = { + toolCallId: item.id, + status, + rawInput: { + agentThreadId: item.agentThreadId, + agentPath: item.agentPath, + activityKind: item.kind, + }, + _meta: { + codex: { + subagent: { + threadId: item.agentThreadId, + path: item.agentPath, + activity: item.kind, + }, + }, + }, + }; + if (sessionUpdate === "tool_call") { + return { + sessionUpdate, + title, + kind: "other", + ...common, + }; + } + return { + sessionUpdate, + ...common, + }; +} + +function formatSubAgentActivityTitle(kind: SubAgentActivityItem["kind"], name: string): string { + switch (kind) { + case "started": + return `Start subagent ${name}`; + case "interacted": + return `Interact with subagent ${name}`; + case "interrupted": + return `Interrupt subagent ${name}`; + } +} + export function formatWebSearchTitle(item: WebSearchItem): string { const action = item.action; if (!action) { diff --git a/src/__tests__/CodexACPAgent/collab-agent-events.test.ts b/src/__tests__/CodexACPAgent/collab-agent-events.test.ts index 9964d2d1..5ad2c72a 100644 --- a/src/__tests__/CodexACPAgent/collab-agent-events.test.ts +++ b/src/__tests__/CodexACPAgent/collab-agent-events.test.ts @@ -84,4 +84,30 @@ describe("CodexEventHandler - collab agent tool call events", () => { "data/collab-agent-tool-call-flow.json" ); }); + + it("maps live subagent activity to an ACP tool call", async () => { + const notifications: ServerNotification[] = [ + { + method: "item/completed", + params: { + threadId: sessionId, + turnId: "turn-1", + completedAtMs: 0, + item: { + type: "subAgentActivity", + id: "call-spawn-weather", + kind: "started", + agentThreadId: "thread-paris", + agentPath: "/root/weather_research", + }, + }, + }, + ]; + + await setupPromptAndSendNotifications(mockFixture, sessionId, sessionState, notifications); + + await expect(`${mockFixture.getAcpConnectionDump([])}\n`).toMatchFileSnapshot( + "data/subagent-activity-flow.json" + ); + }); }); diff --git a/src/__tests__/CodexACPAgent/data/collab-agent-tool-call-flow.json b/src/__tests__/CodexACPAgent/data/collab-agent-tool-call-flow.json index 8e7ed13c..4391e5a9 100644 --- a/src/__tests__/CodexACPAgent/data/collab-agent-tool-call-flow.json +++ b/src/__tests__/CodexACPAgent/data/collab-agent-tool-call-flow.json @@ -21,7 +21,20 @@ "message": "Checking weather" } }, + "model": null, + "reasoningEffort": null, "status": "inProgress" + }, + "_meta": { + "codex": { + "collaboration": { + "tool": "spawnAgent", + "senderThreadId": "thread-main", + "receiverThreadIds": [ + "thread-paris" + ] + } + } } } } @@ -49,7 +62,20 @@ "message": null } }, + "model": null, + "reasoningEffort": null, "status": "completed" + }, + "_meta": { + "codex": { + "collaboration": { + "tool": "spawnAgent", + "senderThreadId": "thread-main", + "receiverThreadIds": [ + "thread-paris" + ] + } + } } } } diff --git a/src/__tests__/CodexACPAgent/data/load-session-history.json b/src/__tests__/CodexACPAgent/data/load-session-history.json index 106786b4..0018e170 100644 --- a/src/__tests__/CodexACPAgent/data/load-session-history.json +++ b/src/__tests__/CodexACPAgent/data/load-session-history.json @@ -400,4 +400,33 @@ } } ] +} +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "session-1", + "update": { + "sessionUpdate": "tool_call", + "title": "Start subagent test_audit", + "kind": "other", + "toolCallId": "item-subagent-1", + "status": "completed", + "rawInput": { + "agentThreadId": "thread-child-1", + "agentPath": "/root/test_audit", + "activityKind": "started" + }, + "_meta": { + "codex": { + "subagent": { + "threadId": "thread-child-1", + "path": "/root/test_audit", + "activity": "started" + } + } + } + } + } + ] } \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/subagent-activity-flow.json b/src/__tests__/CodexACPAgent/data/subagent-activity-flow.json new file mode 100644 index 00000000..e1b6749c --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/subagent-activity-flow.json @@ -0,0 +1,29 @@ +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "test-session-id", + "update": { + "sessionUpdate": "tool_call", + "title": "Start subagent weather_research", + "kind": "other", + "toolCallId": "call-spawn-weather", + "status": "completed", + "rawInput": { + "agentThreadId": "thread-paris", + "agentPath": "/root/weather_research", + "activityKind": "started" + }, + "_meta": { + "codex": { + "subagent": { + "threadId": "thread-paris", + "path": "/root/weather_research", + "activity": "started" + } + } + } + } + } + ] +} diff --git a/src/__tests__/CodexACPAgent/load-session.test.ts b/src/__tests__/CodexACPAgent/load-session.test.ts index 1b2b81e0..a64089e1 100644 --- a/src/__tests__/CodexACPAgent/load-session.test.ts +++ b/src/__tests__/CodexACPAgent/load-session.test.ts @@ -165,6 +165,13 @@ describe("CodexACPAgent - loadSession", () => { type: "contextCompaction", id: "item-context-compaction-1", }, + { + type: "subAgentActivity", + id: "item-subagent-1", + kind: "started", + agentThreadId: "thread-child-1", + agentPath: "/root/test_audit", + }, ], }, ], From 921d466e3aaf747885e395e761cd74ad2d39cd96 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 15 Jul 2026 20:33:23 +0000 Subject: [PATCH 03/25] Release v1.1.4 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2385943a..bf8967a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@agentclientprotocol/codex-acp", - "version": "1.1.3", + "version": "1.1.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@agentclientprotocol/codex-acp", - "version": "1.1.3", + "version": "1.1.4", "license": "Apache-2.0", "dependencies": { "@agentclientprotocol/sdk": "^1.2.1", diff --git a/package.json b/package.json index c2091bab..0c107d36 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "publishConfig": { "access": "public" }, - "version": "1.1.3", + "version": "1.1.4", "description": "", "main": "dist/index.js", "bin": { From b480f0726f89afcfd0815684fce2db4d78da84f8 Mon Sep 17 00:00:00 2001 From: "Ilia.Shulgin" Date: Thu, 16 Jul 2026 17:48:03 +0200 Subject: [PATCH 04/25] build: Trigger registry update after publishing new version --- .github/workflows/publish.yml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b3595b5d..51421d54 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -67,3 +67,27 @@ jobs: with: token: ${{ steps.generate-token.outputs.token }} generate_release_notes: true + + trigger-registry-update: + needs: create-release + runs-on: ubuntu-latest + permissions: {} + steps: + - name: Generate token scoped to the registry repo + uses: actions/create-github-app-token@v3 + id: registry-token + with: + app-id: ${{ secrets.REGISTRY_UPDATER_APP_ID }} + private-key: ${{ secrets.REGISTRY_UPDATER_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: registry + + - name: Dispatch registry version update + env: + GH_TOKEN: ${{ steps.registry-token.outputs.token }} + run: | + gh workflow run update-versions.yml \ + --repo ${{ github.repository_owner }}/registry \ + --ref main \ + -f apply=true \ + -f agents=codex-acp From 3196231fea06a576ea08c8e0a0ad8cf912d1e011 Mon Sep 17 00:00:00 2001 From: "Ilia.Shulgin" Date: Fri, 17 Jul 2026 10:07:05 +0200 Subject: [PATCH 05/25] build: Set jobs environment --- .github/workflows/publish.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 51421d54..0b812b45 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -53,6 +53,7 @@ jobs: create-release: needs: publish-to-npm runs-on: ubuntu-latest + environment: release permissions: {} steps: - name: Generate GitHub token @@ -71,6 +72,7 @@ jobs: trigger-registry-update: needs: create-release runs-on: ubuntu-latest + environment: release permissions: {} steps: - name: Generate token scoped to the registry repo From 2544117c23cbf372acafb346d67d655ad51eb7fa Mon Sep 17 00:00:00 2001 From: "acp-release-bot[bot]" <246668977+acp-release-bot[bot]@users.noreply.github.com> Date: Mon, 20 Jul 2026 12:23:15 +0200 Subject: [PATCH 06/25] Update codex to 0.144.6 (#318) Co-authored-by: github-actions[bot] --- package-lock.json | 56 +++++++++++++++++++++++------------------------ package.json | 2 +- 2 files changed, 29 insertions(+), 29 deletions(-) diff --git a/package-lock.json b/package-lock.json index bf8967a7..caf3186d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "Apache-2.0", "dependencies": { "@agentclientprotocol/sdk": "^1.2.1", - "@openai/codex": "^0.144.4", + "@openai/codex": "^0.144.6", "diff": "^9.0.0", "open": "^11.0.0", "vscode-jsonrpc": "^9.0.1", @@ -929,9 +929,9 @@ } }, "node_modules/@openai/codex": { - "version": "0.144.4", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.4.tgz", - "integrity": "sha512-DTHzYatlKq9dw55E0/HsbK4tRCEKabuJ10ybbqpsG8gVv/kvwEdg3Z4OI3cvLXKa21xkIa4lkGlZoO/HmqmFFw==", + "version": "0.144.6", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.6.tgz", + "integrity": "sha512-wk+2CWiBNXiJLBoN2D08N9RceWkSBnlgk5g2K1a4CXrP/C0gdlHyRUG7RFzm9y41DCK/7tvCct233JVxyFmznw==", "license": "Apache-2.0", "bin": { "codex": "bin/codex.js" @@ -940,19 +940,19 @@ "node": ">=16" }, "optionalDependencies": { - "@openai/codex-darwin-arm64": "npm:@openai/codex@0.144.4-darwin-arm64", - "@openai/codex-darwin-x64": "npm:@openai/codex@0.144.4-darwin-x64", - "@openai/codex-linux-arm64": "npm:@openai/codex@0.144.4-linux-arm64", - "@openai/codex-linux-x64": "npm:@openai/codex@0.144.4-linux-x64", - "@openai/codex-win32-arm64": "npm:@openai/codex@0.144.4-win32-arm64", - "@openai/codex-win32-x64": "npm:@openai/codex@0.144.4-win32-x64" + "@openai/codex-darwin-arm64": "npm:@openai/codex@0.144.6-darwin-arm64", + "@openai/codex-darwin-x64": "npm:@openai/codex@0.144.6-darwin-x64", + "@openai/codex-linux-arm64": "npm:@openai/codex@0.144.6-linux-arm64", + "@openai/codex-linux-x64": "npm:@openai/codex@0.144.6-linux-x64", + "@openai/codex-win32-arm64": "npm:@openai/codex@0.144.6-win32-arm64", + "@openai/codex-win32-x64": "npm:@openai/codex@0.144.6-win32-x64" } }, "node_modules/@openai/codex-darwin-arm64": { "name": "@openai/codex", - "version": "0.144.4-darwin-arm64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.4-darwin-arm64.tgz", - "integrity": "sha512-6J3g498cM2oA7vYIJhpuGJlnIi/M5JdYmjB5BZ1Of5HQ0ziIlplFSvH801oVy9J5TQFp642ODzOu/ZEokDUXsg==", + "version": "0.144.6-darwin-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.6-darwin-arm64.tgz", + "integrity": "sha512-6zgvh70MzBNSeT17HEhSOrmmGGZGAKzSC7x6JAq+edkJkdPYA9P0I1tG7aJ49GlBkBxuC+MKBH1qm6+2Cghcww==", "cpu": [ "arm64" ], @@ -967,9 +967,9 @@ }, "node_modules/@openai/codex-darwin-x64": { "name": "@openai/codex", - "version": "0.144.4-darwin-x64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.4-darwin-x64.tgz", - "integrity": "sha512-k1HC8gdbAy+VmMbekYkhM+r+QE2Xfgd67n1VSp94tjz7aXVKoalHcDkdKNM/uUQ8o2tvbiwhHSUftJF8Sm9/Lw==", + "version": "0.144.6-darwin-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.6-darwin-x64.tgz", + "integrity": "sha512-THRyPG0zSU6M8NQAge1LHEHsJDnoH4BpKsfJHB/qe3Fm+Wf6zqAmWJFlOKzBm27m0K2Hq3za4Ac2I5p5i4yp/A==", "cpu": [ "x64" ], @@ -984,9 +984,9 @@ }, "node_modules/@openai/codex-linux-arm64": { "name": "@openai/codex", - "version": "0.144.4-linux-arm64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.4-linux-arm64.tgz", - "integrity": "sha512-OlKx65579OwIzech9Tt3OUH9+hFZfFrCBP1hL2MudnMIoNr1+cFZjB5YIj5MWMRoBD+K5W3wdBIpQSH855b5Sg==", + "version": "0.144.6-linux-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.6-linux-arm64.tgz", + "integrity": "sha512-PGiLXMN+2IQRkf7tOLi64dMInjU1pRLbz0Rwfj/yt2Y97SZQqAjFQoi2wmswmqtqMDnfwCPTC1DRXVQkvU6T6Q==", "cpu": [ "arm64" ], @@ -1001,9 +1001,9 @@ }, "node_modules/@openai/codex-linux-x64": { "name": "@openai/codex", - "version": "0.144.4-linux-x64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.4-linux-x64.tgz", - "integrity": "sha512-2jxrmV6+/7eBNdg5uhhmOEPFu2o28eYY/ClLzWhSBHH8uo3f2KA1z9JQcVtwlbToW03nEPlEzYNYfCF1UBqsVQ==", + "version": "0.144.6-linux-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.6-linux-x64.tgz", + "integrity": "sha512-4E7EnzCg0OnBxCyYnwJ+qnZwWHYe0YScr5ucKWbngE9u4+0XrpWELqq2Kn9jl5GZK8MDjU7PrJwFIwusHOHjuw==", "cpu": [ "x64" ], @@ -1018,9 +1018,9 @@ }, "node_modules/@openai/codex-win32-arm64": { "name": "@openai/codex", - "version": "0.144.4-win32-arm64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.4-win32-arm64.tgz", - "integrity": "sha512-CCgfI1smFhHZTIpTuBwDJwBr/AR40RTqaFxbBWVabu0RMeYDteRuPiDfdTlktf3C43Y1q10VZXhVGYtCokDg2w==", + "version": "0.144.6-win32-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.6-win32-arm64.tgz", + "integrity": "sha512-SpMjXJLW43JzMP0K62mVcYfmFcpk0BK4AOgYmWSfyZHs3iRtHMd0UYw7605n/9lwkT2EqbwQLT2omZFeKJFzwA==", "cpu": [ "arm64" ], @@ -1035,9 +1035,9 @@ }, "node_modules/@openai/codex-win32-x64": { "name": "@openai/codex", - "version": "0.144.4-win32-x64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.4-win32-x64.tgz", - "integrity": "sha512-iL1ky0ERgdQJOKzom/Ms1fhpwkSmpsA9eVrzAqURFlYGS8z7JqwEgm33+nLGCsY7y25d8Xs/LJ91Oiqz3yXcUg==", + "version": "0.144.6-win32-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.6-win32-x64.tgz", + "integrity": "sha512-dN39VnjEthKz5io1RNWwZDtErdSn07nW3pGUgvlA6DMxgm/nuGaIAZO/sG/Hgxq/x5j9HteAENfrFgVkpZ0lFg==", "cpu": [ "x64" ], diff --git a/package.json b/package.json index 0c107d36..8aaa0b42 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,7 @@ }, "dependencies": { "@agentclientprotocol/sdk": "^1.2.1", - "@openai/codex": "^0.144.4", + "@openai/codex": "^0.144.6", "diff": "^9.0.0", "open": "^11.0.0", "vscode-jsonrpc": "^9.0.1", From 301e0f3ddb385d04cdbc276b5ba91145243f692a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Jul 2026 13:57:11 +0200 Subject: [PATCH 07/25] build(deps): bump the github-actions group with 2 updates (#303) Bumps the github-actions group with 2 updates: [actions/setup-node](https://github.com/actions/setup-node) and [softprops/action-gh-release](https://github.com/softprops/action-gh-release). Updates `actions/setup-node` from 6 to 7 - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/v6...v7) Updates `softprops/action-gh-release` from 3.0.1 to 3.0.2 - [Release notes](https://github.com/softprops/action-gh-release/releases) - [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md) - [Commits](https://github.com/softprops/action-gh-release/compare/718ea10b132b3b2eba29c1007bb80653f286566b...3d0d9888cb7fd7b750713d6e236d1fcb99157228) --- updated-dependencies: - dependency-name: actions/setup-node dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: softprops/action-gh-release dependency-version: 3.0.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: github-actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- .github/workflows/e2e.yml | 2 +- .github/workflows/publish.yml | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6d54e2c9..fc655efd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: contents: read steps: - uses: actions/checkout@v7 - - uses: actions/setup-node@v6 + - uses: actions/setup-node@v7 with: node-version: "24" - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 3a99edcf..afd9c8c6 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -14,7 +14,7 @@ jobs: contents: read steps: - uses: actions/checkout@v7 - - uses: actions/setup-node@v6 + - uses: actions/setup-node@v7 with: node-version: '24' - name: Configure sandboxing diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 0b812b45..6cf4dae5 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -15,7 +15,7 @@ jobs: contents: read steps: - uses: actions/checkout@v7 - - uses: actions/setup-node@v6 + - uses: actions/setup-node@v7 with: node-version: '24' - name: Configure sandboxing @@ -43,7 +43,7 @@ jobs: id-token: write steps: - uses: actions/checkout@v7 - - uses: actions/setup-node@v6 + - uses: actions/setup-node@v7 with: node-version: '24' registry-url: 'https://registry.npmjs.org' @@ -64,7 +64,7 @@ jobs: private-key: ${{ secrets.RELEASE_PLZ_APP_PRIVATE_KEY }} - name: Create Release - uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1 + uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 with: token: ${{ steps.generate-token.outputs.token }} generate_release_notes: true From df18fea2cf52beda312b6fc4acdad5eca7618212 Mon Sep 17 00:00:00 2001 From: Aleksandr Suhinin <95745995+AlexandrSuhinin@users.noreply.github.com> Date: Tue, 21 Jul 2026 12:17:30 +0300 Subject: [PATCH 08/25] fix: handle project MCP config conflicts (#322) --- src/CodexAcpClient.ts | 11 ++++-- .../CodexACPAgent/mcp-config-merge.test.ts | 35 +++++++++++++++++-- 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index b7664d36..3c48232c 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -518,11 +518,16 @@ export class CodexAcpClient { private async getConfigMcpServerNames(projectPath: string): Promise> { const response = await this.codexClient.configRead({ includeLayers: true, cwd: projectPath }); - const mcpServers = response?.config?.["mcp_servers"]; - if (!mcpServers || typeof mcpServers !== "object" || Array.isArray(mcpServers)) { + const effectiveMcpServers = response?.config?.["mcp_servers"]; + const configLayers = response?.layers ?? []; + const layerMcpServers = configLayers.map(layer => { + return isJsonObject(layer.config) ? layer.config["mcp_servers"] : undefined; + }); + const configuredMcpServers = [effectiveMcpServers, ...layerMcpServers].filter(isJsonObject); + if (configuredMcpServers.length === 0) { return new Set(); } - return new Set(Object.keys(mcpServers)); + return new Set(configuredMcpServers.flatMap(server => Object.keys(server))); } getModelProvider(): string | null { diff --git a/src/__tests__/CodexACPAgent/mcp-config-merge.test.ts b/src/__tests__/CodexACPAgent/mcp-config-merge.test.ts index f2cb510c..ac387e5c 100644 --- a/src/__tests__/CodexACPAgent/mcp-config-merge.test.ts +++ b/src/__tests__/CodexACPAgent/mcp-config-merge.test.ts @@ -8,20 +8,30 @@ import type {McpServerStdio} from "@agentclientprotocol/sdk"; import {startCodexConnection} from "../../CodexJsonRpcConnection"; import {createBaseTestFixture, removeDirectoryWithRetry, type TestFixture} from "../acp-test-utils"; -describe('MCP config merge across global config and ACP request', { timeout: 40_000 }, () => { +describe('MCP config merge across configured MCP servers and ACP request', { timeout: 40_000 }, () => { let codexHome: string; + let projectPath: string; let fixture: TestFixture; beforeEach(() => { vi.clearAllMocks(); - const configToml = ` + const globalConfig = ` [mcp_servers.shared-mcp] url = "https://example.com/mcp" `; + + const projectConfig = ` +[mcp_servers.project-mcp] +url = "https://example.com/mcp" +`; + codexHome = fs.mkdtempSync(path.join(os.tmpdir(), "codex-acp-mcp-merge-")); - fs.writeFileSync(path.join(codexHome, "config.toml"), configToml, "utf8"); + fs.writeFileSync(path.join(codexHome, "config.toml"), globalConfig, "utf8"); + projectPath = fs.mkdtempSync(path.join(os.tmpdir(), "codex-acp-mcp-project-")); + fs.mkdirSync(path.join(projectPath, ".codex")); + fs.writeFileSync(path.join(projectPath, ".codex", "config.toml"), projectConfig, "utf8"); const codexConnection = startCodexConnection(undefined, { ...process.env, @@ -37,6 +47,7 @@ url = "https://example.com/mcp" afterEach(() => { vi.unstubAllEnvs(); removeDirectoryWithRetry(codexHome); + removeDirectoryWithRetry(projectPath); }); it('should preserve the global url-based MCP when ACP passes a command-type MCP with the same name', async () => { @@ -68,6 +79,24 @@ url = "https://example.com/mcp" expect(transportDump).contain("- shared-mcp"); }); + it('should preserve a project url-based MCP when ACP passes a command-type MCP with the same name', async () => { + const codexAcpAgent = fixture.getCodexAcpAgent(); + await codexAcpAgent.initialize({protocolVersion: 1}); + fixture.getCodexAcpClient().authRequired = vi.fn().mockResolvedValue(false); + + const conflictingMcp = { + name: "project-mcp", + command: "./node_modules/.bin/mcp-hello-world", + args: ["example"], + env: [{name: "example", value: "example"}], + }; + + await expect(codexAcpAgent.newSession({ + cwd: projectPath, + mcpServers: [conflictingMcp], + })).resolves.toBeDefined(); + }); + it('should not filter the conflicting ACP MCP when config filtering is disabled', async () => { vi.stubEnv("DISABLE_MCP_CONFIG_FILTERING", "true"); const codexAcpAgent = fixture.getCodexAcpAgent(); From 5fae738b2f350228d4cc466a35e3d05b67b91352 Mon Sep 17 00:00:00 2001 From: Aleksandr Suhinin <95745995+AlexandrSuhinin@users.noreply.github.com> Date: Tue, 21 Jul 2026 13:44:26 +0300 Subject: [PATCH 09/25] misc: append workaround clarification for reload configuration errors (#323) --- src/CodexAcpClient.ts | 8 +++++++- src/CodexAcpServer.ts | 4 ++++ .../CodexACPAgent/mcp-config-merge.test.ts | 4 +++- .../CodexACPAgent/new-session-logout.test.ts | 18 ++++++++++++++++++ 4 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index 3c48232c..4961eb37 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -72,6 +72,7 @@ export class CodexAcpClient { private pendingAccountUpdated: Promise | null = null; private readonly sessionNotificationQueues = new Map>(); private skillExtraRoots: string[] = []; + private configPath: string | null = null; constructor(codexClient: CodexAppServerClient, codexConfig?: JsonObject, modelProvider?: string) { @@ -86,7 +87,7 @@ export class CodexAcpClient { }; async initialize(request: acp.InitializeRequest): Promise { - await this.codexClient.initialize({ + const response = await this.codexClient.initialize({ capabilities: { experimentalApi: true, requestAttestation: false, @@ -97,6 +98,11 @@ export class CodexAcpClient { title: request.clientInfo?.title ?? this.defaultClientInfo.title, } }); + this.configPath = response?.codexHome ?? null; + } + + getHomePath(): string | null { + return this.configPath; } async authenticate(authRequest: acp.AuthenticateRequest): Promise { diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index 24a883e7..447721c9 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -310,6 +310,10 @@ export class CodexAcpServer { await this.refreshSessionsAuthState(null); throw RequestError.internalError(`${(e.message)}\n\nYou have been logged out. Please try again.`); } + const configPath = this.codexAcpClient.getHomePath() ?? "global"; + if (e.message.includes("load config")) { + throw RequestError.internalError(`${e.message}\n\nCheck ${configPath} and project .codex directories, especially their config.toml files, or any CODEX_CONFIG override.`); + } } private beginSessionOpen(sessionId: string): number { diff --git a/src/__tests__/CodexACPAgent/mcp-config-merge.test.ts b/src/__tests__/CodexACPAgent/mcp-config-merge.test.ts index ac387e5c..94a92d71 100644 --- a/src/__tests__/CodexACPAgent/mcp-config-merge.test.ts +++ b/src/__tests__/CodexACPAgent/mcp-config-merge.test.ts @@ -114,6 +114,8 @@ url = "https://example.com/mcp" await expect(codexAcpAgent.newSession({ cwd: "", mcpServers: [conflictingMcp], - })).rejects.toThrow("url is not supported for stdio"); + })).rejects.toMatchObject({ + data: expect.stringContaining("url is not supported for stdio"), + }); }); }); diff --git a/src/__tests__/CodexACPAgent/new-session-logout.test.ts b/src/__tests__/CodexACPAgent/new-session-logout.test.ts index f2f331eb..01a7b429 100644 --- a/src/__tests__/CodexACPAgent/new-session-logout.test.ts +++ b/src/__tests__/CodexACPAgent/new-session-logout.test.ts @@ -41,6 +41,24 @@ describe("New session logout handling", () => { expect(logoutSpy).toHaveBeenCalledOnce(); }); + it("includes the global config path in reload configuration errors", async () => { + const fixture = createCodexMockTestFixture(); + const codexAcpAgent = fixture.getCodexAcpAgent(); + const codexAcpClient = fixture.getCodexAcpClient(); + const codexAppServerClient = fixture.getCodexAppServerClient(); + vi.spyOn(codexAcpClient, "authRequired").mockResolvedValue(false); + const logoutSpy = vi.spyOn(codexAcpClient, "logout").mockResolvedValue(); + + const errorMessage = 'Internal error: "failed to reload config: filesystem path `/tmp` must be absolute, use `~/...`, or start with `:`"'; + vi.spyOn(codexAppServerClient, "threadStart").mockRejectedValue(new Error(errorMessage)); + + expect(logoutSpy).toHaveBeenCalledTimes(0); + await expect(codexAcpAgent.newSession({cwd: "", mcpServers: []})) + .rejects.toMatchObject({ + data: expect.stringContaining(`Check global and project .codex directories`), + }); + }); + it("refreshes OpenAI sessions when newSession error forces logout", async () => { const fixture = createCodexMockTestFixture(); const codexAcpAgent = fixture.getCodexAcpAgent(); From ca66e03adbc18072cd3395f140fdcd3c86fd2403 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 21 Jul 2026 11:08:20 +0000 Subject: [PATCH 10/25] Release v1.1.5 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index caf3186d..ea67f9a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@agentclientprotocol/codex-acp", - "version": "1.1.4", + "version": "1.1.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@agentclientprotocol/codex-acp", - "version": "1.1.4", + "version": "1.1.5", "license": "Apache-2.0", "dependencies": { "@agentclientprotocol/sdk": "^1.2.1", diff --git a/package.json b/package.json index 8aaa0b42..db4d61c9 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "publishConfig": { "access": "public" }, - "version": "1.1.4", + "version": "1.1.5", "description": "", "main": "dist/index.js", "bin": { From 2524dfb8568eeac659353ca9705e73501bb403c8 Mon Sep 17 00:00:00 2001 From: Mark Tkachenko Date: Tue, 21 Jul 2026 18:49:04 +0200 Subject: [PATCH 11/25] Steering implementation (#309) * Steering implementation --- examples/steering.ts | 444 ++++++++++++++++++ examples/tsconfig.json | 13 + package.json | 4 +- src/AcpExtensions.ts | 27 +- src/CodexAcpClient.ts | 9 + src/CodexAcpServer.ts | 249 +++++++++- src/CodexAppServerClient.ts | 6 + src/SteeringQueue.ts | 56 +++ .../CodexACPAgent/initialize.test.ts | 5 + .../CodexACPAgent/steer-events.test.ts | 234 +++++++++ src/__tests__/SteeringQueue.test.ts | 115 +++++ src/index.ts | 11 +- tsconfig.json | 2 +- 13 files changed, 1170 insertions(+), 5 deletions(-) create mode 100644 examples/steering.ts create mode 100644 examples/tsconfig.json create mode 100644 src/SteeringQueue.ts create mode 100644 src/__tests__/CodexACPAgent/steer-events.test.ts create mode 100644 src/__tests__/SteeringQueue.test.ts diff --git a/examples/steering.ts b/examples/steering.ts new file mode 100644 index 00000000..f9f34e46 --- /dev/null +++ b/examples/steering.ts @@ -0,0 +1,444 @@ +#!/usr/bin/env tsx + +/** + * Steering demo — mid-turn edition. + * + * The plain `steering.ts` example steers a single-message answer ("count to + * 30"). There the injected prompt can only take effect *after* that message is + * finished: a turn made of one model step has no earlier boundary for Codex to + * inject at, so `turn/steer` appends the message and the model reads it on its + * next step — which is the end. + * + * This example instead gives Codex a genuinely multi-step task: a "treasure + * hunt" where each clue file only reveals the *name of the next clue*. Because + * the reads are sequential and dependent, the agent cannot batch them or read + * ahead — the turn contains several model steps. A steering message injected + * part-way through is therefore picked up *between* steps and visibly changes + * what the agent does next (it stops the hunt early). + * + * Run it with: + * node --import tsx examples/steering.ts + * # or: npm run example:steering:multistep + * + * Auth: uses your existing Codex login in ~/.codex. If you instead export + * CODEX_API_KEY / OPENAI_API_KEY it will authenticate with that. + * + * Knobs (env): + * STEERING_EXAMPLE_MODEL model id (default gpt-5.6-sol) + * STEER_AFTER_TOOL_CALLS inject the steer after N clue reads (default 2) + * NO_COLOR disable ANSI colors + */ + +import * as acp from "@agentclientprotocol/sdk"; +import {type ChildProcess, spawn} from "node:child_process"; +import {fileURLToPath} from "node:url"; +import {mkdtemp, rm, writeFile} from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import {Readable, Writable} from "node:stream"; + +const STEERING_METHOD = "_session/steering"; +const EXAMPLE_TIMEOUT_MS = 60_000; +const DEFAULT_EXAMPLE_MODEL = "gpt-5.6-sol"; +const exampleModel = process.env["STEERING_EXAMPLE_MODEL"] ?? DEFAULT_EXAMPLE_MODEL; + +const parsedSteerAfter = Number(process.env["STEER_AFTER_TOOL_CALLS"] ?? "2"); +const STEER_AFTER_TOOL_CALLS = Number.isFinite(parsedSteerAfter) && parsedSteerAfter > 0 + ? Math.floor(parsedSteerAfter) + : 2; + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +// One note per clue file. The hunt is CLUE_NOTES.length steps long. +const CLUE_NOTES = ["compass", "lantern", "map", "brass key", "torch", "rope", "chart", "chest"]; + +const initialPrompt = [ + "You're solving a treasure hunt inside this folder.", + "Start by reading the file `clue-1.txt`.", + "Each clue holds one note to remember and tells you the exact filename of the next clue.", + "Follow the trail, reading EXACTLY ONE clue per step — use a separate read for each file,", + "and do NOT list the folder or read several files at once.", + "Keep going until a clue tells you to STOP, then reply with the full ordered list of notes you collected.", +].join(" "); + +const steeringPrompt = [ + "Change of plan — stop the treasure hunt immediately.", + "Do not open any more clues.", + "Just tell me the notes you've collected so far and which clue number you stopped on.", +].join(" "); + +type SteeringRequest = { + sessionId: acp.SessionId; + prompt: acp.ContentBlock[]; +}; + +type SteeringResponse = { + outcome: "injected" | "startedNewTurn"; +}; + +type ThreadStatusType = "active" | "idle" | "systemError"; +type StateListener = () => void; + +let trackedSessionId: acp.SessionId | null = null; +const toolCallsSeen = new Set(); +let finishedTransitions = 0; +let lastChannel: string | null = null; +const stateListeners = new Set(); + +// --------------------------------------------------------------------------- +// Tiny ANSI helpers (no dependencies). Honors NO_COLOR and non-TTY output. +// --------------------------------------------------------------------------- +const useColor = Boolean(process.stdout.isTTY) && !process.env["NO_COLOR"]; +const paint = (code: string) => (text: string): string => (useColor ? `\x1b[${code}m${text}\x1b[0m` : text); +const c = { + bold: paint("1"), + dim: paint("2"), + red: paint("31"), + green: paint("32"), + yellow: paint("33"), + magenta: paint("35"), + cyan: paint("36"), +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function createAgentEnvironment(): NodeJS.ProcessEnv { + const configString = process.env["CODEX_CONFIG"]; + let config: Record = {}; + if (configString) { + const parsedConfig: unknown = JSON.parse(configString); + if (!isRecord(parsedConfig)) { + throw new Error("CODEX_CONFIG must contain a JSON object"); + } + config = parsedConfig; + } + return { + ...process.env, + CODEX_CONFIG: JSON.stringify({ + ...config, + model: exampleModel, + }), + }; +} + +function supportsSteering(response: acp.InitializeResponse): boolean { + const steering = response._meta?.["steering"]; + return isRecord(steering) && steering["supported"] === true; +} + +function readThreadStatus(update: acp.SessionUpdate): ThreadStatusType | undefined { + if (update.sessionUpdate !== "session_info_update") { + return undefined; + } + const codex = update._meta?.["codex"]; + if (!isRecord(codex)) { + return undefined; + } + const threadStatus = codex["threadStatus"]; + if (!isRecord(threadStatus)) { + return undefined; + } + const type = threadStatus["type"]; + return type === "active" || type === "idle" || type === "systemError" ? type : undefined; +} + +function notifyStateListeners(): void { + for (const listener of stateListeners) { + listener(); + } +} + +// --------------------------------------------------------------------------- +// Streaming output: group consecutive chunks of the same kind under a header +// so thinking / agent text / tool calls stay visually separated. +// --------------------------------------------------------------------------- +function writeChannel(channel: string, label: string, text: string): void { + if (lastChannel !== channel) { + process.stdout.write(`\n${label}\n`); + lastChannel = channel; + } + process.stdout.write(text); +} + +function writeEvent(line: string): void { + process.stdout.write(`\n${line}\n`); + lastChannel = null; +} + +function recordSessionUpdate(params: acp.SessionNotification): void { + if (params.sessionId !== trackedSessionId) { + return; + } + + const update = params.update; + switch (update.sessionUpdate) { + case "agent_message_chunk": + if (update.content.type === "text") { + writeChannel("message", c.bold(c.cyan("🤖 agent")), update.content.text); + } + break; + case "agent_thought_chunk": + if (update.content.type === "text") { + writeChannel("thought", c.dim("💭 thinking"), c.dim(update.content.text)); + } + break; + case "tool_call": { + const isNew = !toolCallsSeen.has(update.toolCallId); + toolCallsSeen.add(update.toolCallId); + writeEvent(c.yellow(`🔧 tool call #${toolCallsSeen.size}: ${update.title} [${update.status}]`)); + if (isNew) { + notifyStateListeners(); + } + break; + } + case "tool_call_update": + if (update.status) { + writeEvent(c.dim(` ↳ ${update.toolCallId} [${update.status}]`)); + } + break; + } + + const threadStatus = readThreadStatus(update); + if (threadStatus === "idle" || threadStatus === "systemError") { + finishedTransitions += 1; + notifyStateListeners(); + } +} + +async function waitForState( + predicate: () => boolean, + description: string, + timeoutMs = EXAMPLE_TIMEOUT_MS, +): Promise { + if (predicate()) { + return; + } + + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + stateListeners.delete(checkState); + reject(new Error(`Timed out waiting for ${description}`)); + }, timeoutMs); + const checkState = (): void => { + if (!predicate()) { + return; + } + clearTimeout(timeout); + stateListeners.delete(checkState); + resolve(); + }; + stateListeners.add(checkState); + }); +} + +async function createTreasureHunt(): Promise<{workspaceDir: string; clueCount: number}> { + const workspaceDir = await mkdtemp(path.join(os.tmpdir(), "codex-steering-")); + const clueCount = CLUE_NOTES.length; + for (let index = 0; index < clueCount; index += 1) { + const step = index + 1; + const note = CLUE_NOTES[index]; + const isLast = step === clueCount; + const nextInstruction = isLast + ? "This is the final clue. STOP here — do not open any more files. Report every note you collected, in order." + : `When ready, read the next clue in the file named "clue-${step + 1}.txt".`; + const body = + `Treasure hunt — clue ${step} of ${clueCount}\n\n` + + `Note to remember #${step}: ${note}\n\n` + + `${nextInstruction}\n`; + await writeFile(path.join(workspaceDir, `clue-${step}.txt`), body, "utf8"); + } + return {workspaceDir, clueCount}; +} + +function printHeader(workspaceDir: string, clueCount: number): void { + const line = "─".repeat(66); + console.log(c.bold(`\n${line}`)); + console.log(c.bold(" Codex ACP — mid-turn steering demo")); + console.log(line); + console.log(` model : ${c.cyan(exampleModel)}`); + console.log(` workspace : ${c.dim(workspaceDir)}`); + console.log(` clue files : ${clueCount} (clue-1.txt … clue-${clueCount}.txt)`); + console.log(` steer after : ${STEER_AFTER_TOOL_CALLS} tool call(s)`); + console.log(line); + console.log(c.dim(" Task: follow the treasure-hunt chain, one clue at a time.")); + console.log(c.dim(" Mid-turn we inject a steering message telling it to stop early.")); + console.log(`${line}\n`); +} + +function printBanner(text: string): void { + const line = "═".repeat(66); + process.stdout.write(`\n${c.magenta(line)}\n${c.magenta(c.bold(` ${text}`))}\n${c.magenta(line)}\n`); + lastChannel = null; +} + +function printSummary(clueCount: number, cluesAtSteer: number, stopReason: string, steered: boolean): void { + const line = "─".repeat(66); + const stoppedEarly = toolCallsSeen.size < clueCount; + console.log(`\n\n${c.bold(line)}`); + console.log(c.bold(" Summary")); + console.log(line); + console.log(` tool calls total : ${toolCallsSeen.size} of up to ${clueCount} clues`); + console.log(` steered after : ${steered ? `${cluesAtSteer} clue(s)` : "not steered"}`); + console.log(` stop reason : ${stopReason}`); + console.log(line); + if (!steered) { + console.log(c.yellow(" • The turn finished before we could steer. Lower STEER_AFTER_TOOL_CALLS")); + console.log(c.yellow(" or use a slower model to catch the turn while it is still running.")); + } else if (stoppedEarly) { + console.log(c.green(" ✔ The agent stopped BEFORE reading every clue — the steering message")); + console.log(c.green(" was picked up mid-turn and changed its course.")); + } else { + console.log(c.yellow(" • The agent read every clue. Steering still applied, but the turn was")); + console.log(c.yellow(" short — try a longer chain (add CLUE_NOTES) or steer earlier.")); + } + console.log(`${line}\n`); +} + +async function stopAgent(agentProcess: ChildProcess): Promise { + if (agentProcess.stdin && !agentProcess.stdin.destroyed && !agentProcess.stdin.writableEnded) { + agentProcess.stdin.end(); + } + if (agentProcess.exitCode !== null || agentProcess.signalCode !== null) { + return; + } + + await new Promise((resolve) => { + const timeout = setTimeout(resolve, 2_000); + agentProcess.once("exit", () => { + clearTimeout(timeout); + resolve(); + }); + }); + if (agentProcess.exitCode === null && agentProcess.signalCode === null) { + agentProcess.kill(); + } +} + +async function main(): Promise { + const {workspaceDir, clueCount} = await createTreasureHunt(); + const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"; + const agentProcess = spawn(npmCommand, ["run", "--silent", "start"], { + cwd: repositoryRoot, + env: createAgentEnvironment(), + stdio: ["pipe", "pipe", "inherit"], + }); + if (!agentProcess.stdin || !agentProcess.stdout) { + throw new Error("Failed to open stdio pipes for the ACP agent"); + } + const stream = acp.ndJsonStream( + Writable.toWeb(agentProcess.stdin), + Readable.toWeb(agentProcess.stdout) as ReadableStream, + ); + + try { + await acp.client({name: "steering-multistep-example"}) + .onRequest(acp.methods.client.session.requestPermission, (ctx) => { + // A real client would prompt the user here. To keep the demo + // hands-free we auto-approve each read once. + const {toolCall, options} = ctx.params; + const allow = options.find((option) => option.kind === "allow_once") ?? options[0]; + if (!allow) { + return {outcome: {outcome: "cancelled"}}; + } + writeEvent(c.green(` ✔ auto-approving: ${toolCall.title ?? toolCall.toolCallId} → "${allow.name}"`)); + return {outcome: {outcome: "selected", optionId: allow.optionId}}; + }) + .onNotification(acp.methods.client.session.update, (ctx) => { + recordSessionUpdate(ctx.params); + }) + .connectWith(stream, async (agent) => { + const initializeResponse = await agent.request(acp.methods.agent.initialize, { + protocolVersion: acp.PROTOCOL_VERSION, + clientInfo: { + name: "steering-multistep-example", + version: "1.0.0", + }, + }); + if (!supportsSteering(initializeResponse)) { + throw new Error("The agent did not advertise steering support"); + } + + const apiKey = process.env["CODEX_API_KEY"] ?? process.env["OPENAI_API_KEY"]; + if (apiKey && initializeResponse.authMethods?.some((method) => method.id === "api-key")) { + await agent.request(acp.methods.agent.authenticate, { + methodId: "api-key", + _meta: { + "api-key": {apiKey}, + }, + }); + } + + const session = await agent.request(acp.methods.agent.session.new, { + cwd: workspaceDir, + mcpServers: [], + }); + trackedSessionId = session.sessionId; + + printHeader(workspaceDir, clueCount); + process.stdout.write(c.dim(`📤 prompt → ${initialPrompt}\n`)); + + let promptDone = false; + const promptPromise = agent.request(acp.methods.agent.session.prompt, { + sessionId: trackedSessionId, + prompt: [{type: "text", text: initialPrompt}], + }).finally(() => { + promptDone = true; + notifyStateListeners(); + }); + promptPromise.catch(() => {}); + + // Let the agent work through a couple of clues, then steer mid-turn. + await Promise.race([ + waitForState( + () => toolCallsSeen.size >= STEER_AFTER_TOOL_CALLS || promptDone, + `the agent to open ${STEER_AFTER_TOOL_CALLS} clue(s)`, + ).catch(() => {}), + promptPromise.then(() => undefined, () => undefined), + ]); + + const cluesAtSteer = toolCallsSeen.size; + const turnAlreadyFinished = promptDone || finishedTransitions > 0; + let steered = false; + + if (turnAlreadyFinished) { + writeEvent(c.red("⚠ The turn finished before we could steer — skipping the steering step.")); + } else { + steered = true; + printBanner(`Injecting steering message after ${cluesAtSteer} clue(s)`); + process.stdout.write(`${c.magenta(`✋ steer → ${steeringPrompt}`)}\n`); + lastChannel = null; + + const steeringResponse = await agent.request(STEERING_METHOD, { + sessionId: trackedSessionId, + prompt: [{type: "text", text: steeringPrompt}], + }); + if (steeringResponse.outcome !== "injected" && steeringResponse.outcome !== "startedNewTurn") { + throw new Error(`Unexpected steering response: ${JSON.stringify(steeringResponse)}`); + } + writeEvent(c.magenta(c.bold(` outcome: ${steeringResponse.outcome}`))); + if (steeringResponse.outcome === "injected") { + writeEvent(c.dim(" → injected into the running turn; the agent picks it up at its next step.")); + } else { + writeEvent(c.dim(" → the turn had already ended, so this started a fresh turn.")); + } + } + + const promptResponse = await promptPromise; + printSummary(clueCount, cluesAtSteer, promptResponse.stopReason, steered); + + await agent.request(acp.methods.agent.session.close, { + sessionId: trackedSessionId, + }); + }); + } finally { + await stopAgent(agentProcess); + await rm(workspaceDir, {recursive: true, force: true}); + } +} + +main().catch((error: unknown) => { + console.error("Steering multistep example failed:", error); + process.exitCode = 1; +}); diff --git a/examples/tsconfig.json b/examples/tsconfig.json new file mode 100644 index 00000000..b71b3eec --- /dev/null +++ b/examples/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "declaration": false, + "declarationMap": false, + "sourceMap": false + }, + "include": [ + "steering.ts" + ], + "exclude": [] +} diff --git a/package.json b/package.json index db4d61c9..b2576e00 100644 --- a/package.json +++ b/package.json @@ -33,11 +33,13 @@ "package:win-x64": "cd dist/bin && zip codex-acp-x64-windows.zip codex-acp-x64-windows.exe", "package:win-arm64": "cd dist/bin && zip codex-acp-arm64-windows.zip codex-acp-arm64-windows.exe", "start": "node --import tsx src/index.ts", + "example:steering": "node --import tsx examples/steering.ts", + "example:steering:multistep": "node --import tsx examples/steering.ts", "generate-types": "./node_modules/.bin/codex app-server generate-ts --out src/app-server", "test": "vitest run", "test:e2e": "npm run build && RUN_E2E_TESTS=true vitest run src/__tests__/CodexACPAgent/e2e", "test:watch": "vitest", - "typecheck": "tsc --noEmit", + "typecheck": "tsc --noEmit && tsc --noEmit -p examples/tsconfig.json", "codex-test": "tsx .claude/skills/run-codex/scripts/run-codex-test.ts" }, "homepage": "https://github.com/agentclientprotocol/codex-acp#readme", diff --git a/src/AcpExtensions.ts b/src/AcpExtensions.ts index 5a10ea5e..5d8e4602 100644 --- a/src/AcpExtensions.ts +++ b/src/AcpExtensions.ts @@ -1,5 +1,6 @@ import type { ClientContext, + ContentBlock, LoadSessionResponse, NewSessionResponse, ResumeSessionResponse, @@ -7,6 +8,7 @@ import type { } from "@agentclientprotocol/sdk"; export const LEGACY_SET_SESSION_MODEL_METHOD = "session/set_model"; +export const SESSION_STEERING_METHOD = "_session/steering"; export const GOAL_CONTROL_METHOD = "_codex/session/goal_control"; export type LegacySessionModel = { @@ -43,13 +45,15 @@ export type ExtMethodRequest = AuthenticationStatusRequest | AuthenticationLogoutRequest | LegacySetSessionModelExtRequest + | SessionSteeringExtRequest | 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 === GOAL_CONTROL_METHOD; + || request.method === GOAL_CONTROL_METHOD + || request.method === SESSION_STEERING_METHOD; } export type AuthenticationStatusRequest = { method: "authentication/status", params: {} } @@ -79,3 +83,24 @@ export async function legacySetSessionModel( ): Promise { return await connection.request(LEGACY_SET_SESSION_MODEL_METHOD, params); } + +export type SessionSteerRequest = { + sessionId: SessionId; + prompt: ContentBlock[]; +} + +export type SessionSteeringResponse = { + outcome: "injected" | "startedNewTurn" | "failed"; +} + +export type SessionSteeringExtRequest = { + method: typeof SESSION_STEERING_METHOD; + params: SessionSteerRequest; +} + +export async function steerSessionWithFallback( + connection: Pick, + params: SessionSteerRequest, +): Promise { + return await connection.request(SESSION_STEERING_METHOD, params); +} diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index 4961eb37..0c7af207 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -37,6 +37,7 @@ import type { ThreadGoalStatus, ThreadSourceKind, TurnCompletedNotification, + TurnSteerResponse, UserInput, } from "./app-server/v2"; import packageJson from "../package.json"; @@ -844,6 +845,14 @@ export class CodexAcpClient { }); } + async steerTurn(params: { threadId: string, turnId: string, prompt: acp.ContentBlock[] }): Promise { + return await this.codexClient.turnSteer({ + threadId: params.threadId, + expectedTurnId: params.turnId, + input: buildPromptItems(params.prompt), + }); + } + async fetchAvailableModels(): Promise { const models: Model[] = []; let cursor: string | null = null; diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index 447721c9..d911342b 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -35,6 +35,7 @@ import { import type {TokenCount} from "./TokenCount"; import {toPromptUsage} from "./TokenCount"; import {CodexCommands} from "./CodexCommands"; +import {SteeringQueue} from "./SteeringQueue"; import type {QuotaMeta} from "./QuotaMeta"; import {logger} from "./Logger"; import {sanitizeMcpServerName} from "./McpServerName"; @@ -46,9 +47,12 @@ import { type LegacySessionModelState, type LegacySetSessionModelRequest, type LegacySetSessionModelResponse, + type SessionSteerRequest, + type SessionSteeringResponse, GOAL_CONTROL_METHOD, isExtMethodRequest, LEGACY_SET_SESSION_MODEL_METHOD, + SESSION_STEERING_METHOD, } from "./AcpExtensions"; import { createCollabAgentToolCallUpdate, @@ -163,6 +167,7 @@ export class CodexAcpServer { private readonly pendingMcpStartupSessions: Map; private readonly pendingTurnStarts: Map; private readonly activePrompts: Map; + private readonly steeringQueues: Map; private readonly closingSessions: Map; private readonly sessionGenerations: Map; private readonly sessionOpenGenerations: Map; @@ -178,6 +183,7 @@ export class CodexAcpServer { this.pendingMcpStartupSessions = new Map(); this.pendingTurnStarts = new Map(); this.activePrompts = new Map(); + this.steeringQueues = new Map(); this.closingSessions = new Map(); this.sessionGenerations = new Map(); this.sessionOpenGenerations = new Map(); @@ -238,6 +244,11 @@ export class CodexAcpServer { } }, authMethods: getCodexAuthMethods(_params.clientCapabilities), + _meta: { + steering: { + supported: true, + }, + }, }; } @@ -255,6 +266,8 @@ export class CodexAcpServer { } case LEGACY_SET_SESSION_MODEL_METHOD: return await this.unstable_setSessionModel(this.parseLegacySetSessionModelParams(methodRequest.params)); + case SESSION_STEERING_METHOD: + return await this.executeOrQueueSteeringRequest(this.parseSessionSteerParams(methodRequest.params)); case GOAL_CONTROL_METHOD: { const sessionState = this.sessions.get(methodRequest.params.sessionId); if (!sessionState) { @@ -593,6 +606,7 @@ export class CodexAcpServer { this.pendingMcpStartupSessions.delete(params.sessionId); this.pendingTurnStarts.delete(params.sessionId); this.activePrompts.delete(params.sessionId); + this.steeringQueues.delete(params.sessionId); } this.endSessionCloseFence(params.sessionId); } @@ -865,6 +879,233 @@ export class CodexAcpServer { }; } + /** + * Handles one incoming steering request, serialising it against any other + * steer already in flight for the same session. + * + * Every session gets its own {@link SteeringQueue}: the request is enqueued + * and awaited, so concurrent steers for one session run strictly one at a + * time, in arrival order, and can never race to inject into — or start — + * rival turns. Steers for different sessions use different queues and run + * concurrently. Once the queue drains to idle it is removed from the map, + * so no per-session entry leaks after the session goes quiet (the identity + * check guards against deleting a queue a later request has since reused). + * + * @param params The target session id and the prompt to steer with. + * @returns Whether the prompt joined the active turn ("injected"), started a + * new one ("startedNewTurn"), or could not be applied ("failed"); see + * {@link performSteeringRequest}. + */ + async executeOrQueueSteeringRequest(params: SessionSteerRequest): Promise { + const queue = this.getSteeringQueue(params.sessionId); + try { + return await queue.enqueue(params); + } catch (error) { + if (error instanceof RequestError) { + throw error; + } + logger.error(`Steering request for session ${params.sessionId} failed`, error); + return {outcome: "failed"}; + } finally { + if (queue.isIdle && this.steeringQueues.get(params.sessionId) === queue) { + this.steeringQueues.delete(params.sessionId); + } + } + } + + /** + * Returns the steering queue for a session, creating and registering it on + * first use. + * + * @param sessionId The session whose steering queue is required. + * @returns The session's existing queue, or a freshly created one. + */ + private getSteeringQueue(sessionId: string): SteeringQueue { + let queue = this.steeringQueues.get(sessionId); + if (!queue) { + queue = new SteeringQueue((params) => this.performSteeringRequest(params)); + this.steeringQueues.set(sessionId, queue); + } + return queue; + } + + /** + * Delivers a steering prompt to the session: injects it into the live turn + * when there is one, otherwise starts a new turn. + * + * @param params The target session id and the prompt to steer with. + * @returns "injected" when the prompt joined an existing turn, otherwise the + * outcome of starting a new turn. + */ + private async performSteeringRequest(params: SessionSteerRequest): Promise { + logger.log("Steering session requested", { + sessionId: params.sessionId, + prompt: params.prompt, + }); + const sessionState = this.getSessionState(params.sessionId); + this.assertSteerInputSupported(params, sessionState); + + const turnId = await this.getSteerableTurnId(sessionState); + if (turnId) { + const injected = await this.injectSteerIntoActiveTurn(params, turnId, sessionState); + if (injected) { + logger.log("Steering session injected", {sessionId: params.sessionId, turnId}); + return {outcome: "injected"}; + } + } + return await this.startNewTurnFromSteering(params); + } + + /** + * Rejects a steering prompt whose content the active model cannot accept + * (currently: image blocks on a text-only model). + */ + private assertSteerInputSupported(params: SessionSteerRequest, sessionState: SessionState): void { + const hasImage = params.prompt.some(block => block.type === "image"); + if (hasImage && !sessionState.supportedInputModalities.includes("image")) { + throw RequestError.invalidRequest("The current model does not support image input"); + } + } + + /** + * Attempts to inject the prompt into the given running turn. + * + * A failed injection is fatal only when the turn is still the session's + * current turn and Codex reported something other than "no active turn to + * steer". Otherwise the turn has already ended underneath us and the caller + * should start a new turn instead. + * + * @returns true when the prompt was injected; false when the caller should + * fall back to starting a new turn. + */ + private async injectSteerIntoActiveTurn( + params: SessionSteerRequest, + turnId: string, + sessionState: SessionState, + ): Promise { + try { + await this.runWithProcessCheck(() => this.codexAcpClient.steerTurn({ + threadId: params.sessionId, + turnId, + prompt: params.prompt, + })); + return true; + } catch (err) { + await this.codexAcpClient.waitForSessionNotifications(params.sessionId); + const turnStillActive = sessionState.currentTurnId === turnId; + if (turnStillActive && !this.isNoActiveTurnToSteerError(err)) { + throw err; + } + return false; + } + } + + /** + * Starts a new turn from a steering prompt when there is no live turn to + * inject into, and returns as soon as that turn is running. + * + * Waits for any previous prompt to drain first, then re-checks that the + * session is not closing — the await above is a window during which a close + * request can arrive. + * + * @param params The target session id and the prompt to steer with. + * @returns "startedNewTurn" once the turn is running; throws if the prompt + * fails or is cancelled before the turn starts. + */ + private async startNewTurnFromSteering(params: SessionSteerRequest): Promise { + // A prompt can outlive its turn (post-turn cleanup runs before it leaves + // activePrompts), so a steer can miss the turn while the prompt is still + // winding down. Starting a new turn now would run a second prompt on the + // same session, so wait for the current one to drain first (a no-op when idle). + const previousPrompt = this.activePrompts.get(params.sessionId); + await previousPrompt?.completion; + if (this.sessionIsClosing(params.sessionId)) { + throw RequestError.invalidRequest(`Session ${params.sessionId} is closing`); + } + + return await new Promise((resolve, reject) => { + let turnStarted = false; + const promptDone = this.prompt(params, undefined, () => { + turnStarted = true; + logger.log("Steering session started a new turn", {sessionId: params.sessionId}); + // The new turn is now running. This is the success path: answer the + // steer immediately ("a turn was started") and let prompt() finish the + // turn in the background. + resolve({outcome: "startedNewTurn"}); + }); + promptDone.then( + (response) => { + if (!turnStarted && response.stopReason === "cancelled") { + // The prompt ended without the turn ever starting, because it + // was cancelled. The steer never took, so fail the request. + reject(RequestError.invalidRequest(`Session ${params.sessionId} was cancelled before the steering turn started`)); + } else { + // Either the turn already started (this is a no-op after the + // resolve in the callback above), or the prompt finished + // without ever starting a turn and was not cancelled (e.g. a + // command-only turn). Both count as a successfully accepted steer. + resolve({outcome: "startedNewTurn"}); + } + }, + (error: unknown) => { + if (turnStarted) { + // The turn had already started, so the steer was already + // answered "startedNewTurn". This is a failure of a turn running + // in the background — nothing to return, just log it. + logger.error(`Steering-started prompt for session ${params.sessionId} failed`, error); + } else { + // The prompt failed before the turn started. The steer never + // took, so surface the failure to the caller. + reject(error); + } + }, + ); + }); + } + + private isNoActiveTurnToSteerError(error: unknown): boolean { + const messages = error instanceof Error ? [error.message] : []; + if (typeof error === "object" && error !== null && "data" in error) { + const data = (error as {data?: unknown}).data; + if (typeof data === "string") { + messages.push(data); + } else if (typeof data === "object" && data !== null && "details" in data) { + const details = (data as {details?: unknown}).details; + if (typeof details === "string") { + messages.push(details); + } + } + } + return messages.some(message => message.toLowerCase().includes("no active turn to steer")); + } + + private async getSteerableTurnId(sessionState: SessionState): Promise { + if (this.sessionIsClosing(sessionState.sessionId)) { + return null; + } + if (sessionState.currentTurnId) { + return sessionState.currentTurnId; + } + + const pendingTurnStart = this.pendingTurnStarts.get(sessionState.sessionId); + if (!pendingTurnStart) { + return null; + } + return await pendingTurnStart.promise; + } + + private parseSessionSteerParams(params: Record): SessionSteerRequest { + const sessionId = params["sessionId"]; + const prompt = params["prompt"]; + if (typeof sessionId !== "string" || !Array.isArray(prompt)) { + throw RequestError.invalidParams(); + } + return { + sessionId: sessionId, + prompt: prompt as acp.ContentBlock[], + }; + } + private createSessionConfigOptions(sessionState: SessionState): Array { const currentModelId = ModelId.fromString(sessionState.currentModelId); const configOptions = [ @@ -1613,7 +1854,11 @@ export class CodexAcpServer { return turnId; } - async prompt(params: acp.PromptRequest, signal?: AbortSignal): Promise { + async prompt( + params: acp.PromptRequest, + signal?: AbortSignal, + onTurnStarted?: () => void, + ): Promise { logger.log("Prompt received", { sessionId: params.sessionId, prompt: params.prompt, @@ -1666,6 +1911,7 @@ export class CodexAcpServer { } sessionState.currentTurnId = turnId; pendingTurnStart?.resolve(turnId); + onTurnStarted?.(); }, setConfigOption: async (configId, value) => { await this.applySessionConfigOption(sessionState, { @@ -1755,6 +2001,7 @@ export class CodexAcpServer { } sessionState.currentTurnId = turnId; pendingTurnStart?.resolve(turnId); + onTurnStarted?.(); }, () => this.promptShouldStop(params.sessionId, activePrompt), )); diff --git a/src/CodexAppServerClient.ts b/src/CodexAppServerClient.ts index aa88155d..eb26c83f 100644 --- a/src/CodexAppServerClient.ts +++ b/src/CodexAppServerClient.ts @@ -61,6 +61,8 @@ import type { TurnInterruptResponse, TurnStartParams, TurnStartResponse, + TurnSteerParams, + TurnSteerResponse, CommandExecutionRequestApprovalParams, CommandExecutionRequestApprovalResponse, FileChangeRequestApprovalParams, @@ -504,6 +506,10 @@ export class CodexAppServerClient { return await this.sendRequest({ method: "turn/interrupt", params: params }); } + async turnSteer(params: TurnSteerParams): Promise { + return await this.sendRequest({ method: "turn/steer", params: params }); + } + async reviewStart(params: ReviewStartParams): Promise { return await this.sendRequest({ method: "review/start", params: params }); } diff --git a/src/SteeringQueue.ts b/src/SteeringQueue.ts new file mode 100644 index 00000000..c1f553b1 --- /dev/null +++ b/src/SteeringQueue.ts @@ -0,0 +1,56 @@ +import type {SessionSteerRequest, SessionSteeringResponse} from "./AcpExtensions"; + +interface QueuedSteering { + params: SessionSteerRequest; + resolve: (response: SessionSteeringResponse) => void; + reject: (error: unknown) => void; +} + +/** + * Serialises steering requests for a single session. Callers add a request via + * enqueue(); a single consumer loop runs them one at a time, in arrival order, + * so two concurrent steers can never race to start rival turns. + */ +export class SteeringQueue { + private readonly pending: QueuedSteering[] = []; + private processing = false; + + constructor( + private readonly handle: (params: SessionSteerRequest) => Promise, + ) {} + + enqueue(params: SessionSteerRequest): Promise { + return new Promise((resolve, reject) => { + this.pending.push({params, resolve, reject}); + this.startConsumer(); + }); + } + + /** No request is queued and the consumer is not running. */ + get isIdle(): boolean { + return !this.processing && this.pending.length === 0; + } + + private startConsumer(): void { + if (this.processing) { + return; // consumer already draining the queue + } + this.processing = true; + void this.consume(); + } + + private async consume(): Promise { + try { + while (this.pending.length > 0) { + const next = this.pending.shift()!; + try { + next.resolve(await this.handle(next.params)); + } catch (error) { + next.reject(error); // one failed steer must not stall the rest + } + } + } finally { + this.processing = false; + } + } +} diff --git a/src/__tests__/CodexACPAgent/initialize.test.ts b/src/__tests__/CodexACPAgent/initialize.test.ts index 3180835b..9d6dc2b8 100644 --- a/src/__tests__/CodexACPAgent/initialize.test.ts +++ b/src/__tests__/CodexACPAgent/initialize.test.ts @@ -61,6 +61,11 @@ describe('CodexACPAgent - initialize', () => { }, }, authMethods: getCodexAuthMethods(), + _meta: { + steering: { + supported: true, + }, + }, }); }); diff --git a/src/__tests__/CodexACPAgent/steer-events.test.ts b/src/__tests__/CodexACPAgent/steer-events.test.ts new file mode 100644 index 00000000..c13a719b --- /dev/null +++ b/src/__tests__/CodexACPAgent/steer-events.test.ts @@ -0,0 +1,234 @@ +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import type * as acp from "@agentclientprotocol/sdk"; +import {RequestError} from "@agentclientprotocol/sdk"; +import {createCodexMockTestFixture, createTestSessionState} from "../acp-test-utils"; +import type {SessionState} from "../../CodexAcpServer"; +import type {TurnCompletedNotification} from "../../app-server/v2"; +import {SESSION_STEERING_METHOD} from "../../AcpExtensions"; + +function createTurn(id: string, status: "inProgress" | "completed" | "interrupted") { + return { + id, + items: [], + itemsView: "notLoaded" as const, + status, + error: null, + startedAt: null, + completedAt: null, + durationMs: null, + }; +} + +function deferred(): {promise: Promise, resolve: (value: T) => void} { + let resolve: (value: T) => void = () => {}; + const promise = new Promise((innerResolve) => { + resolve = innerResolve; + }); + return {promise, resolve}; +} + +/** + * Drives a prompt to the point where a turn is active (in progress) and paused + * on turn completion, so a steer can be injected mid-turn. + */ +function startActiveTurn(sessionOverrides?: Partial) { + const mockFixture = createCodexMockTestFixture(); + const sessionState = createTestSessionState(sessionOverrides); + vi.spyOn(mockFixture.getCodexAppServerClient(), "turnStart").mockResolvedValue({ + turn: createTurn("turn-id", "inProgress"), + }); + const turnCompleted = deferred(); + vi.spyOn(mockFixture.getCodexAppServerClient(), "awaitTurnCompleted") + .mockReturnValue(turnCompleted.promise); + vi.spyOn(mockFixture.getCodexAcpAgent(), "getSessionState").mockReturnValue(sessionState); + return {mockFixture, sessionState, turnCompleted}; +} + +describe('_session/steering', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('reports injected when the input joins the active turn', async () => { + const {mockFixture, sessionState, turnCompleted} = startActiveTurn(); + const turnSteerSpy = vi.spyOn(mockFixture.getCodexAppServerClient(), "turnSteer") + .mockResolvedValue({turnId: "turn-id"}); + + const promptPromise = mockFixture.getCodexAcpAgent().prompt({ + sessionId: "session-id", + prompt: [{type: "text", text: "long running prompt"}], + }); + await vi.waitFor(() => { + expect(sessionState.currentTurnId).toBe("turn-id"); + }); + + await expect(mockFixture.getCodexAcpAgent().extMethod(SESSION_STEERING_METHOD, { + sessionId: "session-id", + prompt: [{type: "text", text: "also keep backward compatibility"}], + })).resolves.toEqual({outcome: "injected"}); + + expect(turnSteerSpy).toHaveBeenCalledWith({ + threadId: "session-id", + expectedTurnId: "turn-id", + input: [{type: "text", text: "also keep backward compatibility", text_elements: []}], + }); + + turnCompleted.resolve({ + threadId: "session-id", + turn: createTurn("turn-id", "completed"), + }); + await expect(promptPromise).resolves.toMatchObject({stopReason: "end_turn"}); + }); + + it('starts a new turn when no turn is active', async () => { + const mockFixture = createCodexMockTestFixture(); + const sessionState = createTestSessionState(); + vi.spyOn(mockFixture.getCodexAcpAgent(), "getSessionState").mockReturnValue(sessionState); + const turnStartSpy = vi.spyOn(mockFixture.getCodexAppServerClient(), "turnStart") + .mockResolvedValue({turn: createTurn("new-turn-id", "inProgress")}); + const turnCompleted = deferred(); + vi.spyOn(mockFixture.getCodexAppServerClient(), "awaitTurnCompleted") + .mockReturnValue(turnCompleted.promise); + + await expect(mockFixture.getCodexAcpAgent().extMethod(SESSION_STEERING_METHOD, { + sessionId: "session-id", + prompt: [{type: "text", text: "too late for the previous turn"}], + })).resolves.toEqual({outcome: "startedNewTurn"}); + + expect(turnStartSpy).toHaveBeenCalledWith(expect.objectContaining({ + threadId: "session-id", + input: [{type: "text", text: "too late for the previous turn", text_elements: []}], + })); + + turnCompleted.resolve({ + threadId: "session-id", + turn: createTurn("new-turn-id", "completed"), + }); + await vi.waitFor(() => { + expect(sessionState.currentTurnId).toBeNull(); + }); + }); + + it('starts a new turn when Codex reports that the tracked turn is no longer active', async () => { + const {mockFixture, sessionState, turnCompleted} = startActiveTurn(); + const nextTurnCompleted = deferred(); + vi.spyOn(mockFixture.getCodexAppServerClient(), "turnStart") + .mockResolvedValueOnce({turn: createTurn("turn-id", "inProgress")}) + .mockResolvedValueOnce({turn: createTurn("new-turn-id", "inProgress")}); + vi.spyOn(mockFixture.getCodexAppServerClient(), "awaitTurnCompleted") + .mockReturnValueOnce(turnCompleted.promise) + .mockReturnValueOnce(nextTurnCompleted.promise); + vi.spyOn(mockFixture.getCodexAppServerClient(), "turnSteer").mockImplementation(async () => { + turnCompleted.resolve({ + threadId: "session-id", + turn: createTurn("turn-id", "completed"), + }); + throw Object.assign(new Error("Internal error"), { + data: {details: "no active turn to steer"}, + }); + }); + + const promptPromise = mockFixture.getCodexAcpAgent().prompt({ + sessionId: "session-id", + prompt: [{type: "text", text: "long running prompt"}], + }); + await vi.waitFor(() => { + expect(sessionState.currentTurnId).toBe("turn-id"); + }); + + await expect(mockFixture.getCodexAcpAgent().extMethod(SESSION_STEERING_METHOD, { + sessionId: "session-id", + prompt: [{type: "text", text: "racing follow-up"}], + })).resolves.toEqual({outcome: "startedNewTurn"}); + await expect(promptPromise).resolves.toMatchObject({stopReason: "end_turn"}); + expect(sessionState.currentTurnId).toBe("new-turn-id"); + + nextTurnCompleted.resolve({ + threadId: "session-id", + turn: createTurn("new-turn-id", "completed"), + }); + await vi.waitFor(() => { + expect(sessionState.currentTurnId).toBeNull(); + }); + }); + + it('serializes concurrent late steering requests without dropping either prompt', async () => { + const mockFixture = createCodexMockTestFixture(); + const sessionState = createTestSessionState(); + vi.spyOn(mockFixture.getCodexAcpAgent(), "getSessionState").mockReturnValue(sessionState); + vi.spyOn(mockFixture.getCodexAppServerClient(), "turnStart") + .mockResolvedValue({turn: createTurn("new-turn-id", "inProgress")}); + const turnCompleted = deferred(); + vi.spyOn(mockFixture.getCodexAppServerClient(), "awaitTurnCompleted") + .mockReturnValue(turnCompleted.promise); + const turnSteerSpy = vi.spyOn(mockFixture.getCodexAppServerClient(), "turnSteer") + .mockResolvedValue({turnId: "new-turn-id"}); + + const firstRequest = mockFixture.getCodexAcpAgent().extMethod(SESSION_STEERING_METHOD, { + sessionId: "session-id", + prompt: [{type: "text", text: "first late follow-up"}], + }); + const secondRequest = mockFixture.getCodexAcpAgent().extMethod(SESSION_STEERING_METHOD, { + sessionId: "session-id", + prompt: [{type: "text", text: "second late follow-up"}], + }); + + await expect(Promise.all([firstRequest, secondRequest])).resolves.toEqual([ + {outcome: "startedNewTurn"}, + {outcome: "injected"}, + ]); + expect(turnSteerSpy).toHaveBeenCalledWith({ + threadId: "session-id", + expectedTurnId: "new-turn-id", + input: [{type: "text", text: "second late follow-up", text_elements: []}], + }); + + turnCompleted.resolve({ + threadId: "session-id", + turn: createTurn("new-turn-id", "completed"), + }); + await vi.waitFor(() => { + expect(sessionState.currentTurnId).toBeNull(); + }); + }); + + it('reports failed instead of throwing when steering hits an unexpected error', async () => { + const mockFixture = createCodexMockTestFixture(); + vi.spyOn(mockFixture.getCodexAcpAgent(), "getSessionState").mockImplementation(() => { + throw new Error("unexpected boom"); + }); + + await expect(mockFixture.getCodexAcpAgent().extMethod(SESSION_STEERING_METHOD, { + sessionId: "session-id", + prompt: [{type: "text", text: "keep the agent alive"}], + })).resolves.toEqual({outcome: "failed"}); + }); + + it('rejects malformed steer params', async () => { + const mockFixture = createCodexMockTestFixture(); + + await expect(mockFixture.getCodexAcpAgent().extMethod(SESSION_STEERING_METHOD, { + sessionId: "session-id", + })).rejects.toThrow(RequestError); + }); + + it('rejects image input when the model does not support it', async () => { + const {mockFixture} = startActiveTurn({supportedInputModalities: ["text"]}); + const turnSteerSpy = vi.spyOn(mockFixture.getCodexAppServerClient(), "turnSteer"); + + const image: acp.ContentBlock = { + type: "image", + mimeType: "image/png", + data: "abc123", + }; + + const error = await mockFixture.getCodexAcpAgent().extMethod(SESSION_STEERING_METHOD, { + sessionId: "session-id", + prompt: [image], + }).catch((err: unknown) => err); + + expect(error).toBeInstanceOf(RequestError); + expect((error as RequestError).data).toContain("does not support image input"); + expect(turnSteerSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/SteeringQueue.test.ts b/src/__tests__/SteeringQueue.test.ts new file mode 100644 index 00000000..54b5e63b --- /dev/null +++ b/src/__tests__/SteeringQueue.test.ts @@ -0,0 +1,115 @@ +import {describe, expect, it} from "vitest"; +import type {SessionSteerRequest, SessionSteeringResponse} from "../AcpExtensions"; +import {SteeringQueue} from "../SteeringQueue"; + +function request(text: string): SessionSteerRequest { + return {sessionId: "session-id", prompt: [{type: "text", text}]}; +} + +function deferred(): {promise: Promise, resolve: (value: T) => void, reject: (error: unknown) => void} { + let resolve: (value: T) => void = () => {}; + let reject: (error: unknown) => void = () => {}; + const promise = new Promise((innerResolve, innerReject) => { + resolve = innerResolve; + reject = innerReject; + }); + return {promise, resolve, reject}; +} + +describe("SteeringQueue", () => { + it("runs enqueued requests one at a time in arrival order", async () => { + const order: string[] = []; + const queue = new SteeringQueue(async (params) => { + const text = (params.prompt[0] as {text: string}).text; + order.push(`start:${text}`); + await Promise.resolve(); + order.push(`end:${text}`); + return {outcome: "injected"}; + }); + + await Promise.all([ + queue.enqueue(request("a")), + queue.enqueue(request("b")), + queue.enqueue(request("c")), + ]); + + // Each request fully completes before the next one starts. + expect(order).toEqual([ + "start:a", "end:a", + "start:b", "end:b", + "start:c", "end:c", + ]); + }); + + it("never overlaps two handlers", async () => { + let active = 0; + let maxActive = 0; + const queue = new SteeringQueue(async () => { + active++; + maxActive = Math.max(maxActive, active); + await Promise.resolve(); + active--; + return {outcome: "injected"}; + }); + + await Promise.all(Array.from({length: 5}, (_, i) => queue.enqueue(request(`${i}`)))); + + expect(maxActive).toBe(1); + }); + + it("delivers each handler result to its own caller", async () => { + const outcomes: SessionSteeringResponse["outcome"][] = ["injected", "startedNewTurn", "injected"]; + let call = 0; + const queue = new SteeringQueue(async () => ({outcome: outcomes[call++]!})); + + const results = await Promise.all([ + queue.enqueue(request("a")), + queue.enqueue(request("b")), + queue.enqueue(request("c")), + ]); + + expect(results).toEqual([ + {outcome: "injected"}, + {outcome: "startedNewTurn"}, + {outcome: "injected"}, + ]); + }); + + it("rejects only the failing caller and keeps draining the rest", async () => { + const seen: string[] = []; + const queue = new SteeringQueue(async (params) => { + const text = (params.prompt[0] as {text: string}).text; + seen.push(text); + if (text === "boom") { + throw new Error("steer failed"); + } + return {outcome: "injected"}; + }); + + const first = queue.enqueue(request("ok")); + const failing = queue.enqueue(request("boom")); + const third = queue.enqueue(request("after")); + + await expect(first).resolves.toEqual({outcome: "injected"}); + await expect(failing).rejects.toThrow("steer failed"); + await expect(third).resolves.toEqual({outcome: "injected"}); + expect(seen).toEqual(["ok", "boom", "after"]); + }); + + it("reports isIdle before, during, and after processing", async () => { + const gate = deferred(); + const queue = new SteeringQueue(async () => { + await gate.promise; + return {outcome: "injected"}; + }); + + expect(queue.isIdle).toBe(true); + + const inFlight = queue.enqueue(request("a")); + expect(queue.isIdle).toBe(false); + + gate.resolve(); + await inFlight; + expect(queue.isIdle).toBe(true); + }); +}); diff --git a/src/index.ts b/src/index.ts index b2edd2c9..014801ff 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,7 +12,10 @@ import packageJson from "../package.json"; import {logger} from "./Logger"; import {runLoginCommand} from "./login"; import {runCodexCli} from "./CodexCli"; -import {GOAL_CONTROL_METHOD, LEGACY_SET_SESSION_MODEL_METHOD} from "./AcpExtensions"; +import { + GOAL_CONTROL_METHOD, LEGACY_SET_SESSION_MODEL_METHOD, + SESSION_STEERING_METHOD, +} from "./AcpExtensions"; const emptyExtensionParamsParser = z.preprocess( (params) => params ?? {}, @@ -24,6 +27,11 @@ const legacySetSessionModelParamsParser = z.object({ modelId: z.string(), }).passthrough(); +const sessionSteerParamsParser = z.object({ + sessionId: z.string(), + prompt: z.array(z.any()), +}).passthrough(); + const goalControlParamsParser = z.object({ sessionId: z.string(), action: z.enum(["pause", "clear"]), @@ -137,6 +145,7 @@ 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(SESSION_STEERING_METHOD, sessionSteerParamsParser, (ctx) => getAgent().extMethod(SESSION_STEERING_METHOD, ctx.params)) .onRequest(GOAL_CONTROL_METHOD, goalControlParamsParser, (ctx) => getAgent().extMethod(GOAL_CONTROL_METHOD, ctx.params)) .connect(acpJsonStream); } diff --git a/tsconfig.json b/tsconfig.json index 39a4fd81..423a1d8f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -27,5 +27,5 @@ "noUncheckedSideEffectImports": true, "skipLibCheck": true, }, - "exclude": [".claude"] + "exclude": [".claude", "examples"] } From 78a4c1239a56e065a4541e90c50176a6c43a5c2e Mon Sep 17 00:00:00 2001 From: "acp-release-bot[bot]" <246668977+acp-release-bot[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 09:59:46 +0200 Subject: [PATCH 12/25] Update codex to 0.145.0 (#324) * Update codex to 0.145.0 * Fix types and tests after Codex update --------- Co-authored-by: github-actions[bot] --- package-lock.json | 56 ++++++------ package.json | 2 +- src/CodexEventHandler.ts | 3 + src/CodexToolCallMapper.ts | 13 ++- .../CodexACPAgent/CodexAcpClient.test.ts | 2 + .../CodexACPAgent/token-usage-events.test.ts | 34 ++++--- .../CodexACPAgent/web-search-events.test.ts | 4 + src/app-server/ClientRequest.ts | 4 +- src/app-server/CodexResponseHandoffMode.ts | 5 ++ src/app-server/ContentItem.ts | 2 +- .../FunctionCallOutputContentItem.ts | 2 +- src/app-server/InputModality.ts | 2 +- src/app-server/LegacyAppPathString.ts | 13 +-- src/app-server/RealtimeConversationVersion.ts | 2 +- src/app-server/ResponseItem.ts | 7 +- src/app-server/ResponseItemId.ts | 9 ++ src/app-server/ReviewDecision.ts | 2 +- src/app-server/ServerNotification.ts | 4 +- src/app-server/ServerNotificationEnvelope.ts | 89 +++++++++++++++++++ src/app-server/SleepItem.ts | 8 ++ src/app-server/WebSearchItem.ts | 10 ++- src/app-server/index.ts | 4 + src/app-server/v2/Account.ts | 3 +- src/app-server/v2/AppToolSummary.ts | 8 ++ src/app-server/v2/AppsInstalledParams.ts | 17 ++++ src/app-server/v2/AppsInstalledResponse.ts | 9 ++ src/app-server/v2/AppsReadParams.ts | 17 ++++ src/app-server/v2/AppsReadResponse.ts | 9 ++ src/app-server/v2/ConfiguredHookHandler.ts | 9 +- src/app-server/v2/ConnectorMetadata.ts | 9 ++ .../v2/DynamicToolCallOutputContentItem.ts | 2 +- .../v2/EnvironmentConnectionNotification.ts | 5 ++ .../v2/ExternalAgentConfigDetectParams.ts | 11 ++- ...lAgentConfigImportHistoriesReadResponse.ts | 3 +- ...xternalAgentConfigImportItemTypeFailure.ts | 2 +- .../v2/ExternalAgentConfigImportParams.ts | 9 +- .../ExternalAgentConfigMigrationItemType.ts | 2 +- ...ExternalAgentImportedConnectorCandidate.ts | 6 ++ .../ExternalAgentImportedConnectorSource.ts | 5 ++ src/app-server/v2/FileSystemSpecialPath.ts | 3 +- src/app-server/v2/HookEventName.ts | 2 +- src/app-server/v2/HookMetadata.ts | 7 +- src/app-server/v2/InstalledApp.ts | 23 +++++ src/app-server/v2/LoginAccountParams.ts | 2 +- src/app-server/v2/LoginAccountResponse.ts | 2 +- src/app-server/v2/ManagedHooksRequirements.ts | 2 +- src/app-server/v2/McpToolCallAppContext.ts | 2 +- src/app-server/v2/MigrationDetails.ts | 2 +- src/app-server/v2/PluginDetail.ts | 3 +- .../v2/PluginShareUpdateDiscoverability.ts | 2 +- src/app-server/v2/PluginSummary.ts | 2 +- src/app-server/v2/RateLimitSnapshot.ts | 6 +- .../v2/RawResponseCompletedNotification.ts | 10 +++ src/app-server/v2/ScheduledTaskSchedule.ts | 6 ++ src/app-server/v2/ScheduledTaskSummary.ts | 6 ++ src/app-server/v2/ScheduledTaskWeekday.ts | 5 ++ src/app-server/v2/ThreadItem.ts | 3 +- src/app-server/v2/ThreadItemEntry.ts | 10 +++ .../v2/ThreadRealtimeInitialItem.ts | 9 ++ src/app-server/v2/TokenUsageBreakdown.ts | 2 +- src/app-server/v2/TurnEnvironmentParams.ts | 6 +- src/app-server/v2/UserInput.ts | 2 +- src/app-server/v2/index.ts | 16 ++++ 63 files changed, 448 insertions(+), 88 deletions(-) create mode 100644 src/app-server/CodexResponseHandoffMode.ts create mode 100644 src/app-server/ResponseItemId.ts create mode 100644 src/app-server/ServerNotificationEnvelope.ts create mode 100644 src/app-server/SleepItem.ts create mode 100644 src/app-server/v2/AppToolSummary.ts create mode 100644 src/app-server/v2/AppsInstalledParams.ts create mode 100644 src/app-server/v2/AppsInstalledResponse.ts create mode 100644 src/app-server/v2/AppsReadParams.ts create mode 100644 src/app-server/v2/AppsReadResponse.ts create mode 100644 src/app-server/v2/ConnectorMetadata.ts create mode 100644 src/app-server/v2/EnvironmentConnectionNotification.ts create mode 100644 src/app-server/v2/ExternalAgentImportedConnectorCandidate.ts create mode 100644 src/app-server/v2/ExternalAgentImportedConnectorSource.ts create mode 100644 src/app-server/v2/InstalledApp.ts create mode 100644 src/app-server/v2/RawResponseCompletedNotification.ts create mode 100644 src/app-server/v2/ScheduledTaskSchedule.ts create mode 100644 src/app-server/v2/ScheduledTaskSummary.ts create mode 100644 src/app-server/v2/ScheduledTaskWeekday.ts create mode 100644 src/app-server/v2/ThreadItemEntry.ts create mode 100644 src/app-server/v2/ThreadRealtimeInitialItem.ts diff --git a/package-lock.json b/package-lock.json index ea67f9a6..597e5362 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "Apache-2.0", "dependencies": { "@agentclientprotocol/sdk": "^1.2.1", - "@openai/codex": "^0.144.6", + "@openai/codex": "^0.145.0", "diff": "^9.0.0", "open": "^11.0.0", "vscode-jsonrpc": "^9.0.1", @@ -929,9 +929,9 @@ } }, "node_modules/@openai/codex": { - "version": "0.144.6", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.6.tgz", - "integrity": "sha512-wk+2CWiBNXiJLBoN2D08N9RceWkSBnlgk5g2K1a4CXrP/C0gdlHyRUG7RFzm9y41DCK/7tvCct233JVxyFmznw==", + "version": "0.145.0", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.145.0.tgz", + "integrity": "sha512-/PSPSFujjjmiyVFvG2yu/grOFhsWdokTH8t2KGWhXSo/M5n/dIDsnbsnO82/7bLtIoDuzQf7ATBUMWqPWQINlQ==", "license": "Apache-2.0", "bin": { "codex": "bin/codex.js" @@ -940,19 +940,19 @@ "node": ">=16" }, "optionalDependencies": { - "@openai/codex-darwin-arm64": "npm:@openai/codex@0.144.6-darwin-arm64", - "@openai/codex-darwin-x64": "npm:@openai/codex@0.144.6-darwin-x64", - "@openai/codex-linux-arm64": "npm:@openai/codex@0.144.6-linux-arm64", - "@openai/codex-linux-x64": "npm:@openai/codex@0.144.6-linux-x64", - "@openai/codex-win32-arm64": "npm:@openai/codex@0.144.6-win32-arm64", - "@openai/codex-win32-x64": "npm:@openai/codex@0.144.6-win32-x64" + "@openai/codex-darwin-arm64": "npm:@openai/codex@0.145.0-darwin-arm64", + "@openai/codex-darwin-x64": "npm:@openai/codex@0.145.0-darwin-x64", + "@openai/codex-linux-arm64": "npm:@openai/codex@0.145.0-linux-arm64", + "@openai/codex-linux-x64": "npm:@openai/codex@0.145.0-linux-x64", + "@openai/codex-win32-arm64": "npm:@openai/codex@0.145.0-win32-arm64", + "@openai/codex-win32-x64": "npm:@openai/codex@0.145.0-win32-x64" } }, "node_modules/@openai/codex-darwin-arm64": { "name": "@openai/codex", - "version": "0.144.6-darwin-arm64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.6-darwin-arm64.tgz", - "integrity": "sha512-6zgvh70MzBNSeT17HEhSOrmmGGZGAKzSC7x6JAq+edkJkdPYA9P0I1tG7aJ49GlBkBxuC+MKBH1qm6+2Cghcww==", + "version": "0.145.0-darwin-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.145.0-darwin-arm64.tgz", + "integrity": "sha512-h6aQ0UxnaP8mIM/9/qPAH9MNkRliJo88toq1T36IxNM2L5JSU0TFamu+MZn7YkFgDsrp0RfiI+97Tm8AVVxqtA==", "cpu": [ "arm64" ], @@ -967,9 +967,9 @@ }, "node_modules/@openai/codex-darwin-x64": { "name": "@openai/codex", - "version": "0.144.6-darwin-x64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.6-darwin-x64.tgz", - "integrity": "sha512-THRyPG0zSU6M8NQAge1LHEHsJDnoH4BpKsfJHB/qe3Fm+Wf6zqAmWJFlOKzBm27m0K2Hq3za4Ac2I5p5i4yp/A==", + "version": "0.145.0-darwin-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.145.0-darwin-x64.tgz", + "integrity": "sha512-FCYzVKCa9VoLtg9gVyzKpqylonfgZrfcWZN6HsXAZPeuo8CukdMqdgTUOhDn2V6h3MbqS0z6VqQVKUllN/yKhA==", "cpu": [ "x64" ], @@ -984,9 +984,9 @@ }, "node_modules/@openai/codex-linux-arm64": { "name": "@openai/codex", - "version": "0.144.6-linux-arm64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.6-linux-arm64.tgz", - "integrity": "sha512-PGiLXMN+2IQRkf7tOLi64dMInjU1pRLbz0Rwfj/yt2Y97SZQqAjFQoi2wmswmqtqMDnfwCPTC1DRXVQkvU6T6Q==", + "version": "0.145.0-linux-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.145.0-linux-arm64.tgz", + "integrity": "sha512-8OLcPXaAol/FOrRoDxWhIiHIFa73KRsM41EKocjRZOwiT4TcelzJWn3dHyiuSb7teWF25rrslvSPyvhULYRRCQ==", "cpu": [ "arm64" ], @@ -1001,9 +1001,9 @@ }, "node_modules/@openai/codex-linux-x64": { "name": "@openai/codex", - "version": "0.144.6-linux-x64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.6-linux-x64.tgz", - "integrity": "sha512-4E7EnzCg0OnBxCyYnwJ+qnZwWHYe0YScr5ucKWbngE9u4+0XrpWELqq2Kn9jl5GZK8MDjU7PrJwFIwusHOHjuw==", + "version": "0.145.0-linux-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.145.0-linux-x64.tgz", + "integrity": "sha512-u8w8LLv3DvsfrDCoswLIemZ0SoNEXyi511WsfFsSiYUazk9qMsB/NtU8N9vhAfN7mZAxLFoMex4v66JjHuZWwA==", "cpu": [ "x64" ], @@ -1018,9 +1018,9 @@ }, "node_modules/@openai/codex-win32-arm64": { "name": "@openai/codex", - "version": "0.144.6-win32-arm64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.6-win32-arm64.tgz", - "integrity": "sha512-SpMjXJLW43JzMP0K62mVcYfmFcpk0BK4AOgYmWSfyZHs3iRtHMd0UYw7605n/9lwkT2EqbwQLT2omZFeKJFzwA==", + "version": "0.145.0-win32-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.145.0-win32-arm64.tgz", + "integrity": "sha512-sub61rjEFevi1i3Zx7nAd4JM5XxoNFqMqFc5LfTo2xSI8ixHjFvEYDFDXwXOftT04n3Ht1Wh271ioUZpDiEjEg==", "cpu": [ "arm64" ], @@ -1035,9 +1035,9 @@ }, "node_modules/@openai/codex-win32-x64": { "name": "@openai/codex", - "version": "0.144.6-win32-x64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.6-win32-x64.tgz", - "integrity": "sha512-dN39VnjEthKz5io1RNWwZDtErdSn07nW3pGUgvlA6DMxgm/nuGaIAZO/sG/Hgxq/x5j9HteAENfrFgVkpZ0lFg==", + "version": "0.145.0-win32-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.145.0-win32-x64.tgz", + "integrity": "sha512-u0h9lk094CaXRSqE34SBW2dRaQTPa6fASXqehczWH9QdsU62mBsiAgAdp6tCG4i+YzPmmhjD8FdXNnYGNmwuMg==", "cpu": [ "x64" ], diff --git a/package.json b/package.json index b2576e00..e299b070 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,7 @@ }, "dependencies": { "@agentclientprotocol/sdk": "^1.2.1", - "@openai/codex": "^0.144.6", + "@openai/codex": "^0.145.0", "diff": "^9.0.0", "open": "^11.0.0", "vscode-jsonrpc": "^9.0.1", diff --git a/src/CodexEventHandler.ts b/src/CodexEventHandler.ts index 9b83a1d6..5739e44e 100644 --- a/src/CodexEventHandler.ts +++ b/src/CodexEventHandler.ts @@ -190,6 +190,8 @@ export class CodexEventHandler { return this.createTerminalInteractionEvent(notification.params); // ignored events case "thread/deleted": + case "thread/environment/connected": + case "thread/environment/disconnected": case "command/exec/outputDelta": case "hook/started": case "hook/completed": @@ -219,6 +221,7 @@ export class CodexEventHandler { case "mcpServer/oauthLogin/completed": case "externalAgentConfig/import/completed": case "rawResponseItem/completed": + case "rawResponse/completed": case "thread/started": case "item/plan/delta": case "remoteControl/status/changed": diff --git a/src/CodexToolCallMapper.ts b/src/CodexToolCallMapper.ts index c14612eb..3402cfc0 100644 --- a/src/CodexToolCallMapper.ts +++ b/src/CodexToolCallMapper.ts @@ -387,7 +387,7 @@ export function createWebSearchStartUpdate( kind: "search", title: formatWebSearchTitle(item), status: "in_progress", - rawInput: item, + rawInput: createWebSearchRawInput(item), }; } @@ -399,7 +399,16 @@ export function createWebSearchCompleteUpdate( toolCallId: item.id, title: formatWebSearchTitle(item), status: "completed", - rawInput: item, + rawInput: createWebSearchRawInput(item), + }; +} + +function createWebSearchRawInput(item: WebSearchItem): Record { + return { + type: item.type, + id: item.id, + query: item.query, + action: item.action, }; } diff --git a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts index 4399afc8..d58bd594 100644 --- a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts +++ b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts @@ -3340,6 +3340,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { secondary: null, credits: null, individualLimit: null, + spendControlReached: null, planType: null, rateLimitReachedType: null, } @@ -3354,6 +3355,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { secondary: null, credits: null, individualLimit: null, + spendControlReached: null, planType: null, rateLimitReachedType: null, } diff --git a/src/__tests__/CodexACPAgent/token-usage-events.test.ts b/src/__tests__/CodexACPAgent/token-usage-events.test.ts index 2025b2d2..dc49c1fc 100644 --- a/src/__tests__/CodexACPAgent/token-usage-events.test.ts +++ b/src/__tests__/CodexACPAgent/token-usage-events.test.ts @@ -60,6 +60,7 @@ describe('Token Usage Events', () => { totalTokens: 5000, inputTokens: 4000, cachedInputTokens: 1000, + cacheWriteInputTokens: 0, outputTokens: 900, reasoningOutputTokens: 100, }, @@ -67,6 +68,7 @@ describe('Token Usage Events', () => { totalTokens: 2500, inputTokens: 2000, cachedInputTokens: 500, + cacheWriteInputTokens: 0, outputTokens: 450, reasoningOutputTokens: 50, }, @@ -91,6 +93,7 @@ describe('Token Usage Events', () => { totalTokens: 3000, inputTokens: 2500, cachedInputTokens: 0, + cacheWriteInputTokens: 0, outputTokens: 500, reasoningOutputTokens: 0, }, @@ -98,6 +101,7 @@ describe('Token Usage Events', () => { totalTokens: 1500, inputTokens: 1200, cachedInputTokens: 0, + cacheWriteInputTokens: 0, outputTokens: 300, reasoningOutputTokens: 0, }, @@ -132,18 +136,18 @@ describe('Token Usage Events', () => { it('should use last token usage from multiple updates', async () => { const notifications: ServerNotification[] = [ createTokenUsageNotification(sessionId, { - total: { totalTokens: 1000, inputTokens: 800, cachedInputTokens: 0, outputTokens: 200, reasoningOutputTokens: 0 }, - last: { totalTokens: 1000, inputTokens: 800, cachedInputTokens: 0, outputTokens: 200, reasoningOutputTokens: 0 }, + total: { totalTokens: 1000, inputTokens: 800, cachedInputTokens: 0, cacheWriteInputTokens: 0, outputTokens: 200, reasoningOutputTokens: 0 }, + last: { totalTokens: 1000, inputTokens: 800, cachedInputTokens: 0, cacheWriteInputTokens: 0, outputTokens: 200, reasoningOutputTokens: 0 }, modelContextWindow: 128000, }), createTokenUsageNotification(sessionId, { - total: { totalTokens: 2000, inputTokens: 1600, cachedInputTokens: 0, outputTokens: 400, reasoningOutputTokens: 0 }, - last: { totalTokens: 1000, inputTokens: 800, cachedInputTokens: 0, outputTokens: 200, reasoningOutputTokens: 0 }, + total: { totalTokens: 2000, inputTokens: 1600, cachedInputTokens: 0, cacheWriteInputTokens: 0, outputTokens: 400, reasoningOutputTokens: 0 }, + last: { totalTokens: 1000, inputTokens: 800, cachedInputTokens: 0, cacheWriteInputTokens: 0, outputTokens: 200, reasoningOutputTokens: 0 }, modelContextWindow: 128000, }), createTokenUsageNotification(sessionId, { - total: { totalTokens: 3500, inputTokens: 2800, cachedInputTokens: 500, outputTokens: 600, reasoningOutputTokens: 100 }, - last: { totalTokens: 1500, inputTokens: 1200, cachedInputTokens: 500, outputTokens: 200, reasoningOutputTokens: 100 }, + total: { totalTokens: 3500, inputTokens: 2800, cachedInputTokens: 500, cacheWriteInputTokens: 0, outputTokens: 600, reasoningOutputTokens: 100 }, + last: { totalTokens: 1500, inputTokens: 1200, cachedInputTokens: 500, cacheWriteInputTokens: 0, outputTokens: 200, reasoningOutputTokens: 100 }, modelContextWindow: 128000, }), ]; @@ -197,6 +201,7 @@ describe('Token Usage Events', () => { totalTokens: 5000, inputTokens: 4000, cachedInputTokens: 1000, + cacheWriteInputTokens: 0, outputTokens: 900, reasoningOutputTokens: 100, }, @@ -204,6 +209,7 @@ describe('Token Usage Events', () => { totalTokens: 2500, inputTokens: 2000, cachedInputTokens: 500, + cacheWriteInputTokens: 0, outputTokens: 450, reasoningOutputTokens: 50, }, @@ -217,18 +223,18 @@ describe('Token Usage Events', () => { it('should emit latest turn usage from multiple updates', async () => { const events = await setupPromptAndReturnEvents([ createTokenUsageNotification(sessionId, { - total: { totalTokens: 1000, inputTokens: 800, cachedInputTokens: 0, outputTokens: 200, reasoningOutputTokens: 0 }, - last: { totalTokens: 1000, inputTokens: 800, cachedInputTokens: 0, outputTokens: 200, reasoningOutputTokens: 0 }, + total: { totalTokens: 1000, inputTokens: 800, cachedInputTokens: 0, cacheWriteInputTokens: 0, outputTokens: 200, reasoningOutputTokens: 0 }, + last: { totalTokens: 1000, inputTokens: 800, cachedInputTokens: 0, cacheWriteInputTokens: 0, outputTokens: 200, reasoningOutputTokens: 0 }, modelContextWindow: 128000, }), createTokenUsageNotification(sessionId, { - total: { totalTokens: 2000, inputTokens: 1600, cachedInputTokens: 0, outputTokens: 400, reasoningOutputTokens: 0 }, - last: { totalTokens: 1000, inputTokens: 800, cachedInputTokens: 0, outputTokens: 200, reasoningOutputTokens: 0 }, + total: { totalTokens: 2000, inputTokens: 1600, cachedInputTokens: 0, cacheWriteInputTokens: 0, outputTokens: 400, reasoningOutputTokens: 0 }, + last: { totalTokens: 1000, inputTokens: 800, cachedInputTokens: 0, cacheWriteInputTokens: 0, outputTokens: 200, reasoningOutputTokens: 0 }, modelContextWindow: 128000, }), createTokenUsageNotification(sessionId, { - total: { totalTokens: 3500, inputTokens: 2800, cachedInputTokens: 500, outputTokens: 600, reasoningOutputTokens: 100 }, - last: { totalTokens: 1500, inputTokens: 1200, cachedInputTokens: 500, outputTokens: 200, reasoningOutputTokens: 100 }, + total: { totalTokens: 3500, inputTokens: 2800, cachedInputTokens: 500, cacheWriteInputTokens: 0, outputTokens: 600, reasoningOutputTokens: 100 }, + last: { totalTokens: 1500, inputTokens: 1200, cachedInputTokens: 500, cacheWriteInputTokens: 0, outputTokens: 200, reasoningOutputTokens: 100 }, modelContextWindow: 128000, }), ])(); @@ -239,8 +245,8 @@ describe('Token Usage Events', () => { it('should skip usage_update when model context window is unavailable', async () => { const events = await setupPromptAndReturnEvents([ createTokenUsageNotification(sessionId, { - total: { totalTokens: 5000, inputTokens: 4000, cachedInputTokens: 1000, outputTokens: 900, reasoningOutputTokens: 100 }, - last: { totalTokens: 2500, inputTokens: 2000, cachedInputTokens: 500, outputTokens: 450, reasoningOutputTokens: 50 }, + total: { totalTokens: 5000, inputTokens: 4000, cachedInputTokens: 1000, cacheWriteInputTokens: 0, outputTokens: 900, reasoningOutputTokens: 100 }, + last: { totalTokens: 2500, inputTokens: 2000, cachedInputTokens: 500, cacheWriteInputTokens: 0, outputTokens: 450, reasoningOutputTokens: 50 }, modelContextWindow: null, }), ])(); diff --git a/src/__tests__/CodexACPAgent/web-search-events.test.ts b/src/__tests__/CodexACPAgent/web-search-events.test.ts index da1074b1..809bb2b1 100644 --- a/src/__tests__/CodexACPAgent/web-search-events.test.ts +++ b/src/__tests__/CodexACPAgent/web-search-events.test.ts @@ -36,6 +36,7 @@ describe("CodexEventHandler - web search events", () => { type: "webSearch", id: "web-search-1", query: "agent client protocol", + results: null, action: { type: "search", query: "agent client protocol", @@ -54,6 +55,7 @@ describe("CodexEventHandler - web search events", () => { type: "webSearch", id: "web-search-1", query: "agent client protocol", + results: null, action: { type: "search", query: "agent client protocol", @@ -83,6 +85,7 @@ describe("CodexEventHandler - web search events", () => { type: "webSearch", id: "web-open-1", query: "https://agentclientprotocol.com", + results: null, action: { type: "openPage", url: "https://agentclientprotocol.com", @@ -100,6 +103,7 @@ describe("CodexEventHandler - web search events", () => { type: "webSearch", id: "web-find-1", query: "protocol", + results: null, action: { type: "findInPage", url: "https://agentclientprotocol.com/protocol", diff --git a/src/app-server/ClientRequest.ts b/src/app-server/ClientRequest.ts index 6af04ef4..bcfcaf6d 100644 --- a/src/app-server/ClientRequest.ts +++ b/src/app-server/ClientRequest.ts @@ -7,7 +7,9 @@ import type { GetConversationSummaryParams } from "./GetConversationSummaryParam import type { GitDiffToRemoteParams } from "./GitDiffToRemoteParams"; import type { InitializeParams } from "./InitializeParams"; import type { RequestId } from "./RequestId"; +import type { AppsInstalledParams } from "./v2/AppsInstalledParams"; import type { AppsListParams } from "./v2/AppsListParams"; +import type { AppsReadParams } from "./v2/AppsReadParams"; import type { CancelLoginAccountParams } from "./v2/CancelLoginAccountParams"; import type { CommandExecParams } from "./v2/CommandExecParams"; import type { CommandExecResizeParams } from "./v2/CommandExecResizeParams"; @@ -88,4 +90,4 @@ import type { WindowsSandboxSetupStartParams } from "./v2/WindowsSandboxSetupSta /** * Request from the client to the server. */ -export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/delete", id: RequestId, params: ThreadDeleteParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/goal/set", id: RequestId, params: ThreadGoalSetParams, } | { "method": "thread/goal/get", id: RequestId, params: ThreadGoalGetParams, } | { "method": "thread/goal/clear", id: RequestId, params: ThreadGoalClearParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/approveGuardianDeniedAction", id: RequestId, params: ThreadApproveGuardianDeniedActionParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "skills/extraRoots/set", id: RequestId, params: SkillsExtraRootsSetParams, } | { "method": "hooks/list", id: RequestId, params: HooksListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "marketplace/remove", id: RequestId, params: MarketplaceRemoveParams, } | { "method": "marketplace/upgrade", id: RequestId, params: MarketplaceUpgradeParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/installed", id: RequestId, params: PluginInstalledParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "plugin/skill/read", id: RequestId, params: PluginSkillReadParams, } | { "method": "plugin/share/save", id: RequestId, params: PluginShareSaveParams, } | { "method": "plugin/share/updateTargets", id: RequestId, params: PluginShareUpdateTargetsParams, } | { "method": "plugin/share/list", id: RequestId, params: PluginShareListParams, } | { "method": "plugin/share/checkout", id: RequestId, params: PluginShareCheckoutParams, } | { "method": "plugin/share/delete", id: RequestId, params: PluginShareDeleteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "modelProvider/capabilities/read", id: RequestId, params: ModelProviderCapabilitiesReadParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "permissionProfile/list", id: RequestId, params: PermissionProfileListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "mcpServer/tool/call", id: RequestId, params: McpServerToolCallParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "windowsSandbox/readiness", id: RequestId, params: undefined, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "account/rateLimitResetCredit/consume", id: RequestId, params: ConsumeAccountRateLimitResetCreditParams, } | { "method": "account/usage/read", id: RequestId, params: undefined, } | { "method": "account/workspaceMessages/read", id: RequestId, params: undefined, } | { "method": "account/sendAddCreditsNudgeEmail", id: RequestId, params: SendAddCreditsNudgeEmailParams, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "externalAgentConfig/import/readHistories", id: RequestId, params: undefined, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; +export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/delete", id: RequestId, params: ThreadDeleteParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/goal/set", id: RequestId, params: ThreadGoalSetParams, } | { "method": "thread/goal/get", id: RequestId, params: ThreadGoalGetParams, } | { "method": "thread/goal/clear", id: RequestId, params: ThreadGoalClearParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/approveGuardianDeniedAction", id: RequestId, params: ThreadApproveGuardianDeniedActionParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "skills/extraRoots/set", id: RequestId, params: SkillsExtraRootsSetParams, } | { "method": "hooks/list", id: RequestId, params: HooksListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "marketplace/remove", id: RequestId, params: MarketplaceRemoveParams, } | { "method": "marketplace/upgrade", id: RequestId, params: MarketplaceUpgradeParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/installed", id: RequestId, params: PluginInstalledParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "plugin/skill/read", id: RequestId, params: PluginSkillReadParams, } | { "method": "plugin/share/save", id: RequestId, params: PluginShareSaveParams, } | { "method": "plugin/share/updateTargets", id: RequestId, params: PluginShareUpdateTargetsParams, } | { "method": "plugin/share/list", id: RequestId, params: PluginShareListParams, } | { "method": "plugin/share/checkout", id: RequestId, params: PluginShareCheckoutParams, } | { "method": "plugin/share/delete", id: RequestId, params: PluginShareDeleteParams, } | { "method": "app/read", id: RequestId, params: AppsReadParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "app/installed", id: RequestId, params: AppsInstalledParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "modelProvider/capabilities/read", id: RequestId, params: ModelProviderCapabilitiesReadParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "permissionProfile/list", id: RequestId, params: PermissionProfileListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "mcpServer/tool/call", id: RequestId, params: McpServerToolCallParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "windowsSandbox/readiness", id: RequestId, params: undefined, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "account/rateLimitResetCredit/consume", id: RequestId, params: ConsumeAccountRateLimitResetCreditParams, } | { "method": "account/usage/read", id: RequestId, params: undefined, } | { "method": "account/workspaceMessages/read", id: RequestId, params: undefined, } | { "method": "account/sendAddCreditsNudgeEmail", id: RequestId, params: SendAddCreditsNudgeEmailParams, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "externalAgentConfig/import/readHistories", id: RequestId, params: undefined, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; diff --git a/src/app-server/CodexResponseHandoffMode.ts b/src/app-server/CodexResponseHandoffMode.ts new file mode 100644 index 00000000..3eb90dad --- /dev/null +++ b/src/app-server/CodexResponseHandoffMode.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type CodexResponseHandoffMode = "thinking" | "commentary" | "bemTags"; diff --git a/src/app-server/ContentItem.ts b/src/app-server/ContentItem.ts index 21cd8d02..9e53b5fc 100644 --- a/src/app-server/ContentItem.ts +++ b/src/app-server/ContentItem.ts @@ -3,4 +3,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ImageDetail } from "./ImageDetail"; -export type ContentItem = { "type": "input_text", text: string, } | { "type": "input_image", image_url: string, detail?: ImageDetail, } | { "type": "output_text", text: string, }; +export type ContentItem = { "type": "input_text", text: string, } | { "type": "input_image", image_url: string, detail?: ImageDetail, } | { "type": "input_audio", audio_url: string, } | { "type": "output_text", text: string, }; diff --git a/src/app-server/FunctionCallOutputContentItem.ts b/src/app-server/FunctionCallOutputContentItem.ts index cd189081..6c2ab2af 100644 --- a/src/app-server/FunctionCallOutputContentItem.ts +++ b/src/app-server/FunctionCallOutputContentItem.ts @@ -7,4 +7,4 @@ import type { ImageDetail } from "./ImageDetail"; * Responses API compatible content items that can be returned by a tool call. * This is a subset of ContentItem with the types we support as function call outputs. */ -export type FunctionCallOutputContentItem = { "type": "input_text", text: string, } | { "type": "input_image", image_url: string, detail?: ImageDetail, } | { "type": "encrypted_content", encrypted_content: string, }; +export type FunctionCallOutputContentItem = { "type": "input_text", text: string, } | { "type": "input_image", image_url: string, detail?: ImageDetail, } | { "type": "input_audio", audio_url: string, } | { "type": "encrypted_content", encrypted_content: string, }; diff --git a/src/app-server/InputModality.ts b/src/app-server/InputModality.ts index 73661938..40d598df 100644 --- a/src/app-server/InputModality.ts +++ b/src/app-server/InputModality.ts @@ -5,4 +5,4 @@ /** * Canonical user-input modality tags advertised by a model. */ -export type InputModality = "text" | "image"; +export type InputModality = "text" | "image" | "audio"; diff --git a/src/app-server/LegacyAppPathString.ts b/src/app-server/LegacyAppPathString.ts index e39784a8..5c0a1b1e 100644 --- a/src/app-server/LegacyAppPathString.ts +++ b/src/app-server/LegacyAppPathString.ts @@ -16,11 +16,12 @@ * boundary. Non-UTF-8 paths are converted to UTF-8 lossily because this API * value is serialized as a JSON string. * - * Deserialization accepts any UTF-8 string without interpreting or validating - * it. That unrestricted construction path is intentionally available only to - * serde: Codex-internal code cannot construct this type directly from a raw - * `String` and is instead encouraged to convert through [`PathUri`] or - * [`AbsolutePathBuf`]. Relative path text remains valid until an operation - * such as [`Self::to_path_uri`] requires an absolute path. + * Deserialization and [`Self::from_string`] accept any UTF-8 string without + * interpreting or validating it. Use [`Self::from_string`] when a caller + * already owns legacy app-server path text and needs to preserve its wire + * spelling; use [`Self::from_path`], [`Self::from_abs_path`], or + * [`Self::from_path_uri`] when converting an actual path value. Relative + * path text remains valid until an operation such as [`Self::to_path_uri`] + * requires an absolute path. */ export type LegacyAppPathString = string; diff --git a/src/app-server/RealtimeConversationVersion.ts b/src/app-server/RealtimeConversationVersion.ts index cedc4bbe..81b8d311 100644 --- a/src/app-server/RealtimeConversationVersion.ts +++ b/src/app-server/RealtimeConversationVersion.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type RealtimeConversationVersion = "v1" | "v2"; +export type RealtimeConversationVersion = "v1" | "v2" | "v3"; diff --git a/src/app-server/ResponseItem.ts b/src/app-server/ResponseItem.ts index 769b1b08..2758941b 100644 --- a/src/app-server/ResponseItem.ts +++ b/src/app-server/ResponseItem.ts @@ -10,14 +10,15 @@ import type { LocalShellStatus } from "./LocalShellStatus"; import type { MessagePhase } from "./MessagePhase"; import type { ReasoningItemContent } from "./ReasoningItemContent"; import type { ReasoningItemReasoningSummary } from "./ReasoningItemReasoningSummary"; +import type { ResponseItemId } from "./ResponseItemId"; import type { WebSearchAction } from "./WebSearchAction"; -export type ResponseItem = { "type": "message", id?: string, role: string, content: Array, phase?: MessagePhase, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "agent_message", id?: string, author: string, recipient: string, content: Array, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "reasoning", id?: string, summary: Array, content?: Array, encrypted_content: string | null, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "local_shell_call", +export type ResponseItem = { "type": "message", id?: ResponseItemId, role: string, content: Array, phase?: MessagePhase, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "agent_message", id?: ResponseItemId, author: string, recipient: string, content: Array, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "reasoning", id?: ResponseItemId, summary: Array, content?: Array, encrypted_content: string | null, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "local_shell_call", /** * Legacy id field retained for compatibility with older payloads. */ -id?: string, +id?: ResponseItemId, /** * Set when using the Responses API. */ -call_id: string | null, status: LocalShellStatus, action: LocalShellAction, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "function_call", id?: string, name: string, namespace?: string, arguments: string, call_id: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "tool_search_call", id?: string, call_id: string | null, status?: string, execution: string, arguments: unknown, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "function_call_output", id?: string, call_id: string, output: FunctionCallOutputBody, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "custom_tool_call", id?: string, status?: string, call_id: string, name: string, namespace?: string, input: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "custom_tool_call_output", id?: string, call_id: string, name?: string, output: FunctionCallOutputBody, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "tool_search_output", id?: string, call_id: string | null, status: string, execution: string, tools: unknown[], internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "web_search_call", id?: string, status?: string, action?: WebSearchAction, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "image_generation_call", id?: string, status: string, revised_prompt?: string, result: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "compaction", id?: string, encrypted_content: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "compaction_trigger", } | { "type": "context_compaction", id?: string, encrypted_content?: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "other" }; +call_id: string | null, status: LocalShellStatus, action: LocalShellAction, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "function_call", id?: ResponseItemId, name: string, namespace?: string, arguments: string, call_id: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "tool_search_call", id?: ResponseItemId, call_id: string | null, status?: string, execution: string, arguments: unknown, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "function_call_output", id?: ResponseItemId, call_id: string, output: FunctionCallOutputBody, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "custom_tool_call", id?: ResponseItemId, status?: string, call_id: string, name: string, namespace?: string, input: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "custom_tool_call_output", id?: ResponseItemId, call_id: string, name?: string, output: FunctionCallOutputBody, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "tool_search_output", id?: ResponseItemId, call_id: string | null, status: string, execution: string, tools: unknown[], internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "web_search_call", id?: ResponseItemId, status?: string, action?: WebSearchAction, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "image_generation_call", id?: ResponseItemId, status: string, revised_prompt?: string, result: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "compaction", id?: ResponseItemId, encrypted_content: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "compaction_trigger", } | { "type": "context_compaction", id?: ResponseItemId, encrypted_content?: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "other" }; diff --git a/src/app-server/ResponseItemId.ts b/src/app-server/ResponseItemId.ts new file mode 100644 index 00000000..c4f17ec5 --- /dev/null +++ b/src/app-server/ResponseItemId.ts @@ -0,0 +1,9 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * A Responses API item ID. New IDs require an explicit prefix; deserialization + * remains permissive so legacy rollouts can still be read. + */ +export type ResponseItemId = string; diff --git a/src/app-server/ReviewDecision.ts b/src/app-server/ReviewDecision.ts index 109f7292..22c09a24 100644 --- a/src/app-server/ReviewDecision.ts +++ b/src/app-server/ReviewDecision.ts @@ -7,4 +7,4 @@ import type { NetworkPolicyAmendment } from "./NetworkPolicyAmendment"; /** * User's decision in response to an ExecApprovalRequest. */ -export type ReviewDecision = "approved" | { "approved_execpolicy_amendment": { proposed_execpolicy_amendment: ExecPolicyAmendment, } } | "approved_for_session" | { "network_policy_amendment": { network_policy_amendment: NetworkPolicyAmendment, } } | "denied" | "timed_out" | "abort"; +export type ReviewDecision = "approved" | { "approved_execpolicy_amendment": { proposed_execpolicy_amendment: ExecPolicyAmendment, } } | "approved_for_session" | { "network_policy_amendment": { network_policy_amendment: NetworkPolicyAmendment, } } | { "denied": { rejection: string, } } | "timed_out" | "abort"; diff --git a/src/app-server/ServerNotification.ts b/src/app-server/ServerNotification.ts index f3f828b8..271bfd4f 100644 --- a/src/app-server/ServerNotification.ts +++ b/src/app-server/ServerNotification.ts @@ -13,6 +13,7 @@ import type { CommandExecutionOutputDeltaNotification } from "./v2/CommandExecut import type { ConfigWarningNotification } from "./v2/ConfigWarningNotification"; import type { ContextCompactedNotification } from "./v2/ContextCompactedNotification"; import type { DeprecationNoticeNotification } from "./v2/DeprecationNoticeNotification"; +import type { EnvironmentConnectionNotification } from "./v2/EnvironmentConnectionNotification"; import type { ErrorNotification } from "./v2/ErrorNotification"; import type { ExternalAgentConfigImportCompletedNotification } from "./v2/ExternalAgentConfigImportCompletedNotification"; import type { ExternalAgentConfigImportProgressNotification } from "./v2/ExternalAgentConfigImportProgressNotification"; @@ -35,6 +36,7 @@ import type { ModelVerificationNotification } from "./v2/ModelVerificationNotifi import type { PlanDeltaNotification } from "./v2/PlanDeltaNotification"; import type { ProcessExitedNotification } from "./v2/ProcessExitedNotification"; import type { ProcessOutputDeltaNotification } from "./v2/ProcessOutputDeltaNotification"; +import type { RawResponseCompletedNotification } from "./v2/RawResponseCompletedNotification"; import type { RawResponseItemCompletedNotification } from "./v2/RawResponseItemCompletedNotification"; import type { ReasoningSummaryPartAddedNotification } from "./v2/ReasoningSummaryPartAddedNotification"; import type { ReasoningSummaryTextDeltaNotification } from "./v2/ReasoningSummaryTextDeltaNotification"; @@ -74,4 +76,4 @@ import type { WindowsWorldWritableWarningNotification } from "./v2/WindowsWorldW /** * Notification sent from the server to the client. */ -export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/deleted", "params": ThreadDeletedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/goal/updated", "params": ThreadGoalUpdatedNotification } | { "method": "thread/goal/cleared", "params": ThreadGoalClearedNotification } | { "method": "thread/settings/updated", "params": ThreadSettingsUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "hook/started", "params": HookStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "hook/completed", "params": HookCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/autoApprovalReview/started", "params": ItemGuardianApprovalReviewStartedNotification } | { "method": "item/autoApprovalReview/completed", "params": ItemGuardianApprovalReviewCompletedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "command/exec/outputDelta", "params": CommandExecOutputDeltaNotification } | { "method": "process/outputDelta", "params": ProcessOutputDeltaNotification } | { "method": "process/exited", "params": ProcessExitedNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "item/fileChange/patchUpdated", "params": FileChangePatchUpdatedNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "mcpServer/startupStatus/updated", "params": McpServerStatusUpdatedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "remoteControl/status/changed", "params": RemoteControlStatusChangedNotification } | { "method": "externalAgentConfig/import/progress", "params": ExternalAgentConfigImportProgressNotification } | { "method": "externalAgentConfig/import/completed", "params": ExternalAgentConfigImportCompletedNotification } | { "method": "fs/changed", "params": FsChangedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "model/verification", "params": ModelVerificationNotification } | { "method": "turn/moderationMetadata", "params": TurnModerationMetadataNotification } | { "method": "model/safetyBuffering/updated", "params": ModelSafetyBufferingUpdatedNotification } | { "method": "warning", "params": WarningNotification } | { "method": "guardianWarning", "params": GuardianWarningNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/transcript/delta", "params": ThreadRealtimeTranscriptDeltaNotification } | { "method": "thread/realtime/transcript/done", "params": ThreadRealtimeTranscriptDoneNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/sdp", "params": ThreadRealtimeSdpNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification }; +export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/deleted", "params": ThreadDeletedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/goal/updated", "params": ThreadGoalUpdatedNotification } | { "method": "thread/goal/cleared", "params": ThreadGoalClearedNotification } | { "method": "thread/environment/connected", "params": EnvironmentConnectionNotification } | { "method": "thread/environment/disconnected", "params": EnvironmentConnectionNotification } | { "method": "thread/settings/updated", "params": ThreadSettingsUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "hook/started", "params": HookStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "hook/completed", "params": HookCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/autoApprovalReview/started", "params": ItemGuardianApprovalReviewStartedNotification } | { "method": "item/autoApprovalReview/completed", "params": ItemGuardianApprovalReviewCompletedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "rawResponse/completed", "params": RawResponseCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "command/exec/outputDelta", "params": CommandExecOutputDeltaNotification } | { "method": "process/outputDelta", "params": ProcessOutputDeltaNotification } | { "method": "process/exited", "params": ProcessExitedNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "item/fileChange/patchUpdated", "params": FileChangePatchUpdatedNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "mcpServer/startupStatus/updated", "params": McpServerStatusUpdatedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "remoteControl/status/changed", "params": RemoteControlStatusChangedNotification } | { "method": "externalAgentConfig/import/progress", "params": ExternalAgentConfigImportProgressNotification } | { "method": "externalAgentConfig/import/completed", "params": ExternalAgentConfigImportCompletedNotification } | { "method": "fs/changed", "params": FsChangedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "model/verification", "params": ModelVerificationNotification } | { "method": "turn/moderationMetadata", "params": TurnModerationMetadataNotification } | { "method": "model/safetyBuffering/updated", "params": ModelSafetyBufferingUpdatedNotification } | { "method": "warning", "params": WarningNotification } | { "method": "guardianWarning", "params": GuardianWarningNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/transcript/delta", "params": ThreadRealtimeTranscriptDeltaNotification } | { "method": "thread/realtime/transcript/done", "params": ThreadRealtimeTranscriptDoneNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/sdp", "params": ThreadRealtimeSdpNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification }; diff --git a/src/app-server/ServerNotificationEnvelope.ts b/src/app-server/ServerNotificationEnvelope.ts new file mode 100644 index 00000000..46c4fb06 --- /dev/null +++ b/src/app-server/ServerNotificationEnvelope.ts @@ -0,0 +1,89 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { FuzzyFileSearchSessionCompletedNotification } from "./FuzzyFileSearchSessionCompletedNotification"; +import type { FuzzyFileSearchSessionUpdatedNotification } from "./FuzzyFileSearchSessionUpdatedNotification"; +import type { AccountLoginCompletedNotification } from "./v2/AccountLoginCompletedNotification"; +import type { AccountRateLimitsUpdatedNotification } from "./v2/AccountRateLimitsUpdatedNotification"; +import type { AccountUpdatedNotification } from "./v2/AccountUpdatedNotification"; +import type { AgentMessageDeltaNotification } from "./v2/AgentMessageDeltaNotification"; +import type { AppListUpdatedNotification } from "./v2/AppListUpdatedNotification"; +import type { CommandExecOutputDeltaNotification } from "./v2/CommandExecOutputDeltaNotification"; +import type { CommandExecutionOutputDeltaNotification } from "./v2/CommandExecutionOutputDeltaNotification"; +import type { ConfigWarningNotification } from "./v2/ConfigWarningNotification"; +import type { ContextCompactedNotification } from "./v2/ContextCompactedNotification"; +import type { DeprecationNoticeNotification } from "./v2/DeprecationNoticeNotification"; +import type { EnvironmentConnectionNotification } from "./v2/EnvironmentConnectionNotification"; +import type { ErrorNotification } from "./v2/ErrorNotification"; +import type { ExternalAgentConfigImportCompletedNotification } from "./v2/ExternalAgentConfigImportCompletedNotification"; +import type { ExternalAgentConfigImportProgressNotification } from "./v2/ExternalAgentConfigImportProgressNotification"; +import type { FileChangeOutputDeltaNotification } from "./v2/FileChangeOutputDeltaNotification"; +import type { FileChangePatchUpdatedNotification } from "./v2/FileChangePatchUpdatedNotification"; +import type { FsChangedNotification } from "./v2/FsChangedNotification"; +import type { GuardianWarningNotification } from "./v2/GuardianWarningNotification"; +import type { HookCompletedNotification } from "./v2/HookCompletedNotification"; +import type { HookStartedNotification } from "./v2/HookStartedNotification"; +import type { ItemCompletedNotification } from "./v2/ItemCompletedNotification"; +import type { ItemGuardianApprovalReviewCompletedNotification } from "./v2/ItemGuardianApprovalReviewCompletedNotification"; +import type { ItemGuardianApprovalReviewStartedNotification } from "./v2/ItemGuardianApprovalReviewStartedNotification"; +import type { ItemStartedNotification } from "./v2/ItemStartedNotification"; +import type { McpServerOauthLoginCompletedNotification } from "./v2/McpServerOauthLoginCompletedNotification"; +import type { McpServerStatusUpdatedNotification } from "./v2/McpServerStatusUpdatedNotification"; +import type { McpToolCallProgressNotification } from "./v2/McpToolCallProgressNotification"; +import type { ModelReroutedNotification } from "./v2/ModelReroutedNotification"; +import type { ModelSafetyBufferingUpdatedNotification } from "./v2/ModelSafetyBufferingUpdatedNotification"; +import type { ModelVerificationNotification } from "./v2/ModelVerificationNotification"; +import type { PlanDeltaNotification } from "./v2/PlanDeltaNotification"; +import type { ProcessExitedNotification } from "./v2/ProcessExitedNotification"; +import type { ProcessOutputDeltaNotification } from "./v2/ProcessOutputDeltaNotification"; +import type { RawResponseCompletedNotification } from "./v2/RawResponseCompletedNotification"; +import type { RawResponseItemCompletedNotification } from "./v2/RawResponseItemCompletedNotification"; +import type { ReasoningSummaryPartAddedNotification } from "./v2/ReasoningSummaryPartAddedNotification"; +import type { ReasoningSummaryTextDeltaNotification } from "./v2/ReasoningSummaryTextDeltaNotification"; +import type { ReasoningTextDeltaNotification } from "./v2/ReasoningTextDeltaNotification"; +import type { RemoteControlStatusChangedNotification } from "./v2/RemoteControlStatusChangedNotification"; +import type { ServerRequestResolvedNotification } from "./v2/ServerRequestResolvedNotification"; +import type { SkillsChangedNotification } from "./v2/SkillsChangedNotification"; +import type { TerminalInteractionNotification } from "./v2/TerminalInteractionNotification"; +import type { ThreadArchivedNotification } from "./v2/ThreadArchivedNotification"; +import type { ThreadClosedNotification } from "./v2/ThreadClosedNotification"; +import type { ThreadDeletedNotification } from "./v2/ThreadDeletedNotification"; +import type { ThreadGoalClearedNotification } from "./v2/ThreadGoalClearedNotification"; +import type { ThreadGoalUpdatedNotification } from "./v2/ThreadGoalUpdatedNotification"; +import type { ThreadNameUpdatedNotification } from "./v2/ThreadNameUpdatedNotification"; +import type { ThreadRealtimeClosedNotification } from "./v2/ThreadRealtimeClosedNotification"; +import type { ThreadRealtimeErrorNotification } from "./v2/ThreadRealtimeErrorNotification"; +import type { ThreadRealtimeItemAddedNotification } from "./v2/ThreadRealtimeItemAddedNotification"; +import type { ThreadRealtimeOutputAudioDeltaNotification } from "./v2/ThreadRealtimeOutputAudioDeltaNotification"; +import type { ThreadRealtimeSdpNotification } from "./v2/ThreadRealtimeSdpNotification"; +import type { ThreadRealtimeStartedNotification } from "./v2/ThreadRealtimeStartedNotification"; +import type { ThreadRealtimeTranscriptDeltaNotification } from "./v2/ThreadRealtimeTranscriptDeltaNotification"; +import type { ThreadRealtimeTranscriptDoneNotification } from "./v2/ThreadRealtimeTranscriptDoneNotification"; +import type { ThreadSettingsUpdatedNotification } from "./v2/ThreadSettingsUpdatedNotification"; +import type { ThreadStartedNotification } from "./v2/ThreadStartedNotification"; +import type { ThreadStatusChangedNotification } from "./v2/ThreadStatusChangedNotification"; +import type { ThreadTokenUsageUpdatedNotification } from "./v2/ThreadTokenUsageUpdatedNotification"; +import type { ThreadUnarchivedNotification } from "./v2/ThreadUnarchivedNotification"; +import type { TurnCompletedNotification } from "./v2/TurnCompletedNotification"; +import type { TurnDiffUpdatedNotification } from "./v2/TurnDiffUpdatedNotification"; +import type { TurnModerationMetadataNotification } from "./v2/TurnModerationMetadataNotification"; +import type { TurnPlanUpdatedNotification } from "./v2/TurnPlanUpdatedNotification"; +import type { TurnStartedNotification } from "./v2/TurnStartedNotification"; +import type { WarningNotification } from "./v2/WarningNotification"; +import type { WindowsSandboxSetupCompletedNotification } from "./v2/WindowsSandboxSetupCompletedNotification"; +import type { WindowsWorldWritableWarningNotification } from "./v2/WindowsWorldWritableWarningNotification"; + +/** + * Server notification envelope sent over app-server transports. + * + * `emitted_at_ms` records when app-server emitted the notification, before it + * is fanned out to individual connections. + */ +export type ServerNotificationEnvelope = { +/** + * Unix timestamp (in milliseconds) when app-server emitted this notification. + * + * Optional so clients can decode notifications from older app-server + * versions. Current app-server versions always populate it. + */ +emittedAtMs?: number, } & ({ "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/deleted", "params": ThreadDeletedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/goal/updated", "params": ThreadGoalUpdatedNotification } | { "method": "thread/goal/cleared", "params": ThreadGoalClearedNotification } | { "method": "thread/environment/connected", "params": EnvironmentConnectionNotification } | { "method": "thread/environment/disconnected", "params": EnvironmentConnectionNotification } | { "method": "thread/settings/updated", "params": ThreadSettingsUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "hook/started", "params": HookStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "hook/completed", "params": HookCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/autoApprovalReview/started", "params": ItemGuardianApprovalReviewStartedNotification } | { "method": "item/autoApprovalReview/completed", "params": ItemGuardianApprovalReviewCompletedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "rawResponse/completed", "params": RawResponseCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "command/exec/outputDelta", "params": CommandExecOutputDeltaNotification } | { "method": "process/outputDelta", "params": ProcessOutputDeltaNotification } | { "method": "process/exited", "params": ProcessExitedNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "item/fileChange/patchUpdated", "params": FileChangePatchUpdatedNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "mcpServer/startupStatus/updated", "params": McpServerStatusUpdatedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "remoteControl/status/changed", "params": RemoteControlStatusChangedNotification } | { "method": "externalAgentConfig/import/progress", "params": ExternalAgentConfigImportProgressNotification } | { "method": "externalAgentConfig/import/completed", "params": ExternalAgentConfigImportCompletedNotification } | { "method": "fs/changed", "params": FsChangedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "model/verification", "params": ModelVerificationNotification } | { "method": "turn/moderationMetadata", "params": TurnModerationMetadataNotification } | { "method": "model/safetyBuffering/updated", "params": ModelSafetyBufferingUpdatedNotification } | { "method": "warning", "params": WarningNotification } | { "method": "guardianWarning", "params": GuardianWarningNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/transcript/delta", "params": ThreadRealtimeTranscriptDeltaNotification } | { "method": "thread/realtime/transcript/done", "params": ThreadRealtimeTranscriptDoneNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/sdp", "params": ThreadRealtimeSdpNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification }); diff --git a/src/app-server/SleepItem.ts b/src/app-server/SleepItem.ts new file mode 100644 index 00000000..b399551c --- /dev/null +++ b/src/app-server/SleepItem.ts @@ -0,0 +1,8 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Display item emitted by the interruptible `clock.sleep` tool. + */ +export type SleepItem = { id: string, durationMs: number, }; diff --git a/src/app-server/WebSearchItem.ts b/src/app-server/WebSearchItem.ts index bc1e6d54..9ce72a2f 100644 --- a/src/app-server/WebSearchItem.ts +++ b/src/app-server/WebSearchItem.ts @@ -1,6 +1,14 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { JsonValue } from "./serde_json/JsonValue"; import type { WebSearchAction } from "./v2/WebSearchAction"; -export type WebSearchItem = { id: string, query: string, action: WebSearchAction | null, }; +export type WebSearchItem = { id: string, query: string, action: WebSearchAction | null, +/** + * Structured search results returned out-of-band by standalone web search. + * + * These stay as opaque JSON at the extension/app-server boundary so new + * result fields and result types can pass through without a Codex release. + */ +results: Array | null, }; diff --git a/src/app-server/index.ts b/src/app-server/index.ts index 747a0a66..cd3b3f53 100644 --- a/src/app-server/index.ts +++ b/src/app-server/index.ts @@ -39,6 +39,7 @@ export type { ClientInfo } from "./ClientInfo"; export type { ClientNotification } from "./ClientNotification"; export type { ClientRequest } from "./ClientRequest"; export type { CodexErrorInfo } from "./CodexErrorInfo"; +export type { CodexResponseHandoffMode } from "./CodexResponseHandoffMode"; export type { CollabAgentInteractionBeginEvent } from "./CollabAgentInteractionBeginEvent"; export type { CollabAgentInteractionEndEvent } from "./CollabAgentInteractionEndEvent"; export type { CollabAgentRef } from "./CollabAgentRef"; @@ -232,6 +233,7 @@ export type { ResourceContent } from "./ResourceContent"; export type { ResourceLink } from "./ResourceLink"; export type { ResourceTemplate } from "./ResourceTemplate"; export type { ResponseItem } from "./ResponseItem"; +export type { ResponseItemId } from "./ResponseItemId"; export type { ResponseItemMetadata } from "./ResponseItemMetadata"; export type { ResumeConversationParams } from "./ResumeConversationParams"; export type { ResumeConversationResponse } from "./ResumeConversationResponse"; @@ -253,6 +255,7 @@ export type { SendUserMessageResponse } from "./SendUserMessageResponse"; export type { SendUserTurnParams } from "./SendUserTurnParams"; export type { SendUserTurnResponse } from "./SendUserTurnResponse"; export type { ServerNotification } from "./ServerNotification"; +export type { ServerNotificationEnvelope } from "./ServerNotificationEnvelope"; export type { ServerRequest } from "./ServerRequest"; export type { ServiceTier } from "./ServiceTier"; export type { SessionConfiguredEvent } from "./SessionConfiguredEvent"; @@ -270,6 +273,7 @@ export type { SkillRequestApprovalEvent } from "./SkillRequestApprovalEvent"; export type { SkillScope } from "./SkillScope"; export type { SkillToolDependency } from "./SkillToolDependency"; export type { SkillsListEntry } from "./SkillsListEntry"; +export type { SleepItem } from "./SleepItem"; export type { StepStatus } from "./StepStatus"; export type { StreamErrorEvent } from "./StreamErrorEvent"; export type { SubAgentSource } from "./SubAgentSource"; diff --git a/src/app-server/v2/Account.ts b/src/app-server/v2/Account.ts index 1b7953e5..1f1ad851 100644 --- a/src/app-server/v2/Account.ts +++ b/src/app-server/v2/Account.ts @@ -1,7 +1,6 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { AmazonBedrockCredentialSource } from "../AmazonBedrockCredentialSource"; import type { PlanType } from "../PlanType"; -export type Account = { "type": "apiKey", } | { "type": "chatgpt", email: string | null, planType: PlanType, } | { "type": "amazonBedrock", credentialSource: AmazonBedrockCredentialSource, }; +export type Account = { "type": "apiKey", } | { "type": "chatgpt", email: string | null, planType: PlanType, } | { "type": "amazonBedrock", usesCodexManagedCredentials: boolean, }; diff --git a/src/app-server/v2/AppToolSummary.ts b/src/app-server/v2/AppToolSummary.ts new file mode 100644 index 00000000..15677b0c --- /dev/null +++ b/src/app-server/v2/AppToolSummary.ts @@ -0,0 +1,8 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * EXPERIMENTAL - metadata returned by app/read. + */ +export type AppToolSummary = { name: string, title: string | null, description: string, }; diff --git a/src/app-server/v2/AppsInstalledParams.ts b/src/app-server/v2/AppsInstalledParams.ts new file mode 100644 index 00000000..d832da6d --- /dev/null +++ b/src/app-server/v2/AppsInstalledParams.ts @@ -0,0 +1,17 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Read the committed installed connector runtime snapshot. + */ +export type AppsInstalledParams = { +/** + * Optional loaded thread id used to evaluate effective app configuration. + */ +threadId?: string | null, +/** + * When true and Apps are permitted, refresh and publish the hosted connector runtime tool + * snapshot first. + */ +forceRefresh?: boolean, }; diff --git a/src/app-server/v2/AppsInstalledResponse.ts b/src/app-server/v2/AppsInstalledResponse.ts new file mode 100644 index 00000000..4978452a --- /dev/null +++ b/src/app-server/v2/AppsInstalledResponse.ts @@ -0,0 +1,9 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { InstalledApp } from "./InstalledApp"; + +/** + * The installed connectors in one committed runtime snapshot. + */ +export type AppsInstalledResponse = { apps: Array, }; diff --git a/src/app-server/v2/AppsReadParams.ts b/src/app-server/v2/AppsReadParams.ts new file mode 100644 index 00000000..4d58f86b --- /dev/null +++ b/src/app-server/v2/AppsReadParams.ts @@ -0,0 +1,17 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * EXPERIMENTAL - read metadata for specific apps/connectors. + */ +export type AppsReadParams = { +/** + * App ids to read. The server accepts at most 100 ids and deduplicates repeated ids while + * preserving their first-request order. + */ +appIds: Array, +/** + * When true, include display-only public tool summaries in the returned metadata. + */ +includeTools?: boolean, }; diff --git a/src/app-server/v2/AppsReadResponse.ts b/src/app-server/v2/AppsReadResponse.ts new file mode 100644 index 00000000..308d7dde --- /dev/null +++ b/src/app-server/v2/AppsReadResponse.ts @@ -0,0 +1,9 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ConnectorMetadata } from "./ConnectorMetadata"; + +/** + * EXPERIMENTAL - app/read response. + */ +export type AppsReadResponse = { apps: Array, missingAppIds: Array, }; diff --git a/src/app-server/v2/ConfiguredHookHandler.ts b/src/app-server/v2/ConfiguredHookHandler.ts index 42b05cf8..38adeb2b 100644 --- a/src/app-server/v2/ConfiguredHookHandler.ts +++ b/src/app-server/v2/ConfiguredHookHandler.ts @@ -2,4 +2,11 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type ConfiguredHookHandler = { "type": "command", command: string, commandWindows: string | null, timeoutSec: bigint | null, async: boolean, statusMessage: string | null, } | { "type": "prompt", } | { "type": "agent", }; +export type ConfiguredHookHandler = { "type": "command", command: string, commandWindows: string | null, timeoutSec: bigint | null, async: boolean, statusMessage: string | null, +/** + * Approximate token threshold for spilling this hook's `additionalContext` to disk. + * `null` uses 2,500 tokens; `0` disables spilling for this hook. The threshold is + * evaluated against the original context; a spilled preview also includes recovery + * metadata. + */ +additionalContextLimit: number | null, } | { "type": "prompt", } | { "type": "agent", }; diff --git a/src/app-server/v2/ConnectorMetadata.ts b/src/app-server/v2/ConnectorMetadata.ts new file mode 100644 index 00000000..54c18a78 --- /dev/null +++ b/src/app-server/v2/ConnectorMetadata.ts @@ -0,0 +1,9 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AppToolSummary } from "./AppToolSummary"; + +/** + * EXPERIMENTAL - metadata returned by app/read. + */ +export type ConnectorMetadata = { id: string, name: string, description: string | null, iconUrl: string | null, iconUrlDark: string | null, distributionChannel: string | null, installUrl: string | null, pluginDisplayNames: Array, toolSummaries: Array | null, }; diff --git a/src/app-server/v2/DynamicToolCallOutputContentItem.ts b/src/app-server/v2/DynamicToolCallOutputContentItem.ts index 8f432109..9be1a809 100644 --- a/src/app-server/v2/DynamicToolCallOutputContentItem.ts +++ b/src/app-server/v2/DynamicToolCallOutputContentItem.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type DynamicToolCallOutputContentItem = { "type": "inputText", text: string, } | { "type": "inputImage", imageUrl: string, }; +export type DynamicToolCallOutputContentItem = { "type": "inputText", text: string, } | { "type": "inputImage", imageUrl: string, } | { "type": "inputAudio", audioUrl: string, }; diff --git a/src/app-server/v2/EnvironmentConnectionNotification.ts b/src/app-server/v2/EnvironmentConnectionNotification.ts new file mode 100644 index 00000000..518f75c0 --- /dev/null +++ b/src/app-server/v2/EnvironmentConnectionNotification.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type EnvironmentConnectionNotification = { threadId: string, environmentId: string, }; diff --git a/src/app-server/v2/ExternalAgentConfigDetectParams.ts b/src/app-server/v2/ExternalAgentConfigDetectParams.ts index 48b55e9b..5d43fb44 100644 --- a/src/app-server/v2/ExternalAgentConfigDetectParams.ts +++ b/src/app-server/v2/ExternalAgentConfigDetectParams.ts @@ -10,4 +10,13 @@ includeHome?: boolean, /** * Zero or more working directories to include for repo-scoped detection. */ -cwds?: Array | null, }; +cwds?: Array | null, +/** + * Deprecated field retained for compatibility. This field is ignored; use `migrationSource` + * to select the migration source. + */ +source?: string | null, +/** + * Optional migration-source selector. Missing or unrecognized values use the default source. + */ +migrationSource?: string | null, }; diff --git a/src/app-server/v2/ExternalAgentConfigImportHistoriesReadResponse.ts b/src/app-server/v2/ExternalAgentConfigImportHistoriesReadResponse.ts index 9df61f55..b48aa224 100644 --- a/src/app-server/v2/ExternalAgentConfigImportHistoriesReadResponse.ts +++ b/src/app-server/v2/ExternalAgentConfigImportHistoriesReadResponse.ts @@ -2,5 +2,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ExternalAgentConfigImportHistory } from "./ExternalAgentConfigImportHistory"; +import type { ExternalAgentImportedConnectorCandidate } from "./ExternalAgentImportedConnectorCandidate"; -export type ExternalAgentConfigImportHistoriesReadResponse = { data: Array, }; +export type ExternalAgentConfigImportHistoriesReadResponse = { data: Array, connectors: Array, }; diff --git a/src/app-server/v2/ExternalAgentConfigImportItemTypeFailure.ts b/src/app-server/v2/ExternalAgentConfigImportItemTypeFailure.ts index 13e02bf1..f2f6ebc5 100644 --- a/src/app-server/v2/ExternalAgentConfigImportItemTypeFailure.ts +++ b/src/app-server/v2/ExternalAgentConfigImportItemTypeFailure.ts @@ -3,4 +3,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ExternalAgentConfigMigrationItemType } from "./ExternalAgentConfigMigrationItemType"; -export type ExternalAgentConfigImportItemTypeFailure = { itemType: ExternalAgentConfigMigrationItemType, errorType: string | null, failureStage: string, message: string, cwd: string | null, source: string | null, }; +export type ExternalAgentConfigImportItemTypeFailure = { itemType: ExternalAgentConfigMigrationItemType, errorType: string | null, subErrorType: string | null, failureStage: string, message: string, cwd: string | null, source: string | null, }; diff --git a/src/app-server/v2/ExternalAgentConfigImportParams.ts b/src/app-server/v2/ExternalAgentConfigImportParams.ts index be7f7ffe..8c28680b 100644 --- a/src/app-server/v2/ExternalAgentConfigImportParams.ts +++ b/src/app-server/v2/ExternalAgentConfigImportParams.ts @@ -5,6 +5,11 @@ import type { ExternalAgentConfigMigrationItem } from "./ExternalAgentConfigMigr export type ExternalAgentConfigImportParams = { migrationItems: Array, /** - * Source product that produced the migration items. Missing means unspecified. + * Optional identifier for the product that initiated the import. */ -source?: string | null, }; +source?: string | null, +/** + * Migration-source selector used to produce the migration items. Pass the same value to + * detection and import; missing or unrecognized values use the default source. + */ +migrationSource?: string | null, }; diff --git a/src/app-server/v2/ExternalAgentConfigMigrationItemType.ts b/src/app-server/v2/ExternalAgentConfigMigrationItemType.ts index d8576937..b356690e 100644 --- a/src/app-server/v2/ExternalAgentConfigMigrationItemType.ts +++ b/src/app-server/v2/ExternalAgentConfigMigrationItemType.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type ExternalAgentConfigMigrationItemType = "AGENTS_MD" | "CONFIG" | "SKILLS" | "PLUGINS" | "MCP_SERVER_CONFIG" | "SUBAGENTS" | "HOOKS" | "COMMANDS" | "SESSIONS"; +export type ExternalAgentConfigMigrationItemType = "AGENTS_MD" | "CONFIG" | "SKILLS" | "PLUGINS" | "MCP_SERVER_CONFIG" | "SUBAGENTS" | "HOOKS" | "COMMANDS" | "MEMORY" | "SESSIONS"; diff --git a/src/app-server/v2/ExternalAgentImportedConnectorCandidate.ts b/src/app-server/v2/ExternalAgentImportedConnectorCandidate.ts new file mode 100644 index 00000000..9aad5f5a --- /dev/null +++ b/src/app-server/v2/ExternalAgentImportedConnectorCandidate.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ExternalAgentImportedConnectorSource } from "./ExternalAgentImportedConnectorSource"; + +export type ExternalAgentImportedConnectorCandidate = { name: string, sessionCount: number, source: ExternalAgentImportedConnectorSource, }; diff --git a/src/app-server/v2/ExternalAgentImportedConnectorSource.ts b/src/app-server/v2/ExternalAgentImportedConnectorSource.ts new file mode 100644 index 00000000..5398eb44 --- /dev/null +++ b/src/app-server/v2/ExternalAgentImportedConnectorSource.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ExternalAgentImportedConnectorSource = "remoteMcpServersConfig"; diff --git a/src/app-server/v2/FileSystemSpecialPath.ts b/src/app-server/v2/FileSystemSpecialPath.ts index f4dc2b01..10c69e3e 100644 --- a/src/app-server/v2/FileSystemSpecialPath.ts +++ b/src/app-server/v2/FileSystemSpecialPath.ts @@ -1,5 +1,6 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { LegacyAppPathString } from "../LegacyAppPathString"; -export type FileSystemSpecialPath = { "kind": "root" } | { "kind": "minimal" } | { "kind": "project_roots", subpath: string | null, } | { "kind": "tmpdir" } | { "kind": "slash_tmp" } | { "kind": "unknown", path: string, subpath: string | null, }; +export type FileSystemSpecialPath = { "kind": "root" } | { "kind": "minimal" } | { "kind": "project_roots", subpath: LegacyAppPathString | null, } | { "kind": "tmpdir" } | { "kind": "slash_tmp" } | { "kind": "unknown", path: string, subpath: LegacyAppPathString | null, }; diff --git a/src/app-server/v2/HookEventName.ts b/src/app-server/v2/HookEventName.ts index 47747628..ae8a7f38 100644 --- a/src/app-server/v2/HookEventName.ts +++ b/src/app-server/v2/HookEventName.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type HookEventName = "preToolUse" | "permissionRequest" | "postToolUse" | "preCompact" | "postCompact" | "sessionStart" | "userPromptSubmit" | "subagentStart" | "subagentStop" | "stop"; +export type HookEventName = "preToolUse" | "permissionRequest" | "postToolUse" | "preCompact" | "postCompact" | "sessionStart" | "sessionEnd" | "userPromptSubmit" | "subagentStart" | "subagentStop" | "stop"; diff --git a/src/app-server/v2/HookMetadata.ts b/src/app-server/v2/HookMetadata.ts index 94e3c30c..82244f0a 100644 --- a/src/app-server/v2/HookMetadata.ts +++ b/src/app-server/v2/HookMetadata.ts @@ -7,4 +7,9 @@ import type { HookHandlerType } from "./HookHandlerType"; import type { HookSource } from "./HookSource"; import type { HookTrustStatus } from "./HookTrustStatus"; -export type HookMetadata = { key: string, eventName: HookEventName, handlerType: HookHandlerType, matcher: string | null, command: string | null, timeoutSec: bigint, statusMessage: string | null, sourcePath: AbsolutePathBuf, source: HookSource, pluginId: string | null, displayOrder: bigint, enabled: boolean, isManaged: boolean, currentHash: string, trustStatus: HookTrustStatus, }; +export type HookMetadata = { key: string, eventName: HookEventName, handlerType: HookHandlerType, matcher: string | null, command: string | null, timeoutSec: bigint, statusMessage: string | null, +/** + * Configured `additionalContext` spill threshold. + * `null` uses 2,500 tokens; `0` disables spilling. + */ +additionalContextLimit: number | null, sourcePath: AbsolutePathBuf, source: HookSource, pluginId: string | null, displayOrder: bigint, enabled: boolean, isManaged: boolean, currentHash: string, trustStatus: HookTrustStatus, }; diff --git a/src/app-server/v2/InstalledApp.ts b/src/app-server/v2/InstalledApp.ts new file mode 100644 index 00000000..9fce592d --- /dev/null +++ b/src/app-server/v2/InstalledApp.ts @@ -0,0 +1,23 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Installed connector runtime state. + */ +export type InstalledApp = { id: string, +/** + * Best-effort name carried by the runtime tool catalog. Canonical app metadata remains owned + * by `app/read`. + */ +runtimeName: string | null, +/** + * Effective enabled state after applying global, workspace, local, and managed configuration + * at read time. + */ +enabled: boolean, +/** + * Whether the connector is enabled and has a non-synthetic, model-visible tool allowed by + * effective MCP and app/tool policy in the committed runtime snapshot. + */ +callable: boolean, }; diff --git a/src/app-server/v2/LoginAccountParams.ts b/src/app-server/v2/LoginAccountParams.ts index 1606f167..41d6075e 100644 --- a/src/app-server/v2/LoginAccountParams.ts +++ b/src/app-server/v2/LoginAccountParams.ts @@ -19,4 +19,4 @@ chatgptAccountId: string, * When `null`, Codex attempts to derive the plan type from access-token * claims. If unavailable, the plan defaults to `unknown`. */ -chatgptPlanType?: string | null, }; +chatgptPlanType?: string | null, } | { "type": "amazonBedrock", apiKey: string, region: string, }; diff --git a/src/app-server/v2/LoginAccountResponse.ts b/src/app-server/v2/LoginAccountResponse.ts index 34bccd65..5a9f34ea 100644 --- a/src/app-server/v2/LoginAccountResponse.ts +++ b/src/app-server/v2/LoginAccountResponse.ts @@ -14,4 +14,4 @@ verificationUrl: string, /** * One-time code the user must enter after signing in. */ -userCode: string, } | { "type": "chatgptAuthTokens", }; +userCode: string, } | { "type": "chatgptAuthTokens", } | { "type": "amazonBedrock", }; diff --git a/src/app-server/v2/ManagedHooksRequirements.ts b/src/app-server/v2/ManagedHooksRequirements.ts index 1143bd01..6d49d5f0 100644 --- a/src/app-server/v2/ManagedHooksRequirements.ts +++ b/src/app-server/v2/ManagedHooksRequirements.ts @@ -3,4 +3,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ConfiguredHookMatcherGroup } from "./ConfiguredHookMatcherGroup"; -export type ManagedHooksRequirements = { managedDir: string | null, windowsManagedDir: string | null, PreToolUse: Array, PermissionRequest: Array, PostToolUse: Array, PreCompact: Array, PostCompact: Array, SessionStart: Array, UserPromptSubmit: Array, SubagentStart: Array, SubagentStop: Array, Stop: Array, }; +export type ManagedHooksRequirements = { managedDir: string | null, windowsManagedDir: string | null, PreToolUse: Array, PermissionRequest: Array, PostToolUse: Array, PreCompact: Array, PostCompact: Array, SessionStart: Array, SessionEnd: Array, UserPromptSubmit: Array, SubagentStart: Array, SubagentStop: Array, Stop: Array, }; diff --git a/src/app-server/v2/McpToolCallAppContext.ts b/src/app-server/v2/McpToolCallAppContext.ts index e4bc5a11..28c28453 100644 --- a/src/app-server/v2/McpToolCallAppContext.ts +++ b/src/app-server/v2/McpToolCallAppContext.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type McpToolCallAppContext = { connectorId: string, linkId: string | null, resourceUri: string | null, appName: string | null, templateId: string | null, actionName: string | null, }; +export type McpToolCallAppContext = { connectorId: string, linkId: string | null, resourceUri: string | null, appName: string | null, actionName: string | null, }; diff --git a/src/app-server/v2/MigrationDetails.ts b/src/app-server/v2/MigrationDetails.ts index 21f77984..3c99c3e7 100644 --- a/src/app-server/v2/MigrationDetails.ts +++ b/src/app-server/v2/MigrationDetails.ts @@ -9,4 +9,4 @@ import type { SessionMigration } from "./SessionMigration"; import type { SkillMigration } from "./SkillMigration"; import type { SubagentMigration } from "./SubagentMigration"; -export type MigrationDetails = { plugins: Array, skills: Array, sessions: Array, mcpServers: Array, hooks: Array, subagents: Array, commands: Array, }; +export type MigrationDetails = { plugins: Array, skills: Array, sessions: Array, mcpServers: Array, hooks: Array, subagents: Array, commands: Array, memory?: Array, }; diff --git a/src/app-server/v2/PluginDetail.ts b/src/app-server/v2/PluginDetail.ts index ab2e3fd5..d4bf3f82 100644 --- a/src/app-server/v2/PluginDetail.ts +++ b/src/app-server/v2/PluginDetail.ts @@ -6,6 +6,7 @@ import type { AppSummary } from "./AppSummary"; import type { AppTemplateSummary } from "./AppTemplateSummary"; import type { PluginHookSummary } from "./PluginHookSummary"; import type { PluginSummary } from "./PluginSummary"; +import type { ScheduledTaskSummary } from "./ScheduledTaskSummary"; import type { SkillSummary } from "./SkillSummary"; -export type PluginDetail = { marketplaceName: string, marketplacePath: AbsolutePathBuf | null, summary: PluginSummary, shareUrl: string | null, description: string | null, skills: Array, hooks: Array, apps: Array, appTemplates: Array, mcpServers: Array, }; +export type PluginDetail = { marketplaceName: string, marketplacePath: AbsolutePathBuf | null, summary: PluginSummary, shareUrl: string | null, description: string | null, skills: Array, hooks: Array, apps: Array, appTemplates: Array, mcpServers: Array, scheduledTasks: Array | null, }; diff --git a/src/app-server/v2/PluginShareUpdateDiscoverability.ts b/src/app-server/v2/PluginShareUpdateDiscoverability.ts index fd601987..767acae9 100644 --- a/src/app-server/v2/PluginShareUpdateDiscoverability.ts +++ b/src/app-server/v2/PluginShareUpdateDiscoverability.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type PluginShareUpdateDiscoverability = "UNLISTED" | "PRIVATE"; +export type PluginShareUpdateDiscoverability = "UNLISTED" | "PRIVATE" | "LISTED"; diff --git a/src/app-server/v2/PluginSummary.ts b/src/app-server/v2/PluginSummary.ts index 10fd5aa2..b02eba16 100644 --- a/src/app-server/v2/PluginSummary.ts +++ b/src/app-server/v2/PluginSummary.ts @@ -25,7 +25,7 @@ localVersion: string | null, name: string, /** * Remote sharing context associated with this plugin when available. */ -shareContext: PluginShareContext | null, source: PluginSource, installed: boolean, enabled: boolean, installPolicy: PluginInstallPolicy, installPolicySource: PluginInstallPolicySource | null, authPolicy: PluginAuthPolicy, +shareContext: PluginShareContext | null, source: PluginSource, installed: boolean, enabled: boolean, installPolicy: PluginInstallPolicy, installPolicySource: PluginInstallPolicySource | null, mustShowInstallationInterstitial: boolean | null, authPolicy: PluginAuthPolicy, /** * Availability state for installing and using the plugin. */ diff --git a/src/app-server/v2/RateLimitSnapshot.ts b/src/app-server/v2/RateLimitSnapshot.ts index c1e3953d..13c1604b 100644 --- a/src/app-server/v2/RateLimitSnapshot.ts +++ b/src/app-server/v2/RateLimitSnapshot.ts @@ -7,4 +7,8 @@ import type { RateLimitReachedType } from "./RateLimitReachedType"; import type { RateLimitWindow } from "./RateLimitWindow"; import type { SpendControlLimitSnapshot } from "./SpendControlLimitSnapshot"; -export type RateLimitSnapshot = { limitId: string | null, limitName: string | null, primary: RateLimitWindow | null, secondary: RateLimitWindow | null, credits: CreditsSnapshot | null, individualLimit: SpendControlLimitSnapshot | null, planType: PlanType | null, rateLimitReachedType: RateLimitReachedType | null, }; +export type RateLimitSnapshot = { limitId: string | null, limitName: string | null, primary: RateLimitWindow | null, secondary: RateLimitWindow | null, credits: CreditsSnapshot | null, individualLimit: SpendControlLimitSnapshot | null, +/** + * Backend-reported spend-control state. `None` is unavailable, not a sparse-update recovery. + */ +spendControlReached: boolean | null, planType: PlanType | null, rateLimitReachedType: RateLimitReachedType | null, }; diff --git a/src/app-server/v2/RawResponseCompletedNotification.ts b/src/app-server/v2/RawResponseCompletedNotification.ts new file mode 100644 index 00000000..b06e74b4 --- /dev/null +++ b/src/app-server/v2/RawResponseCompletedNotification.ts @@ -0,0 +1,10 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { TokenUsageBreakdown } from "./TokenUsageBreakdown"; + +/** + * Internal-only notification containing the exact usage from one upstream + * Responses API completion. + */ +export type RawResponseCompletedNotification = { threadId: string, turnId: string, responseId: string, usage: TokenUsageBreakdown | null, }; diff --git a/src/app-server/v2/ScheduledTaskSchedule.ts b/src/app-server/v2/ScheduledTaskSchedule.ts new file mode 100644 index 00000000..c8171273 --- /dev/null +++ b/src/app-server/v2/ScheduledTaskSchedule.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ScheduledTaskWeekday } from "./ScheduledTaskWeekday"; + +export type ScheduledTaskSchedule = { "type": "hourly", intervalHours: number, days: Array | null, } | { "type": "daily", time: string, } | { "type": "weekdays", time: string, } | { "type": "weekly", days: Array, time: string, }; diff --git a/src/app-server/v2/ScheduledTaskSummary.ts b/src/app-server/v2/ScheduledTaskSummary.ts new file mode 100644 index 00000000..91f7f954 --- /dev/null +++ b/src/app-server/v2/ScheduledTaskSummary.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ScheduledTaskSchedule } from "./ScheduledTaskSchedule"; + +export type ScheduledTaskSummary = { key: string, name: string, prompt: string, schedule: ScheduledTaskSchedule, }; diff --git a/src/app-server/v2/ScheduledTaskWeekday.ts b/src/app-server/v2/ScheduledTaskWeekday.ts new file mode 100644 index 00000000..bf21096a --- /dev/null +++ b/src/app-server/v2/ScheduledTaskWeekday.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ScheduledTaskWeekday = "MO" | "TU" | "WE" | "TH" | "FR" | "SA" | "SU"; diff --git a/src/app-server/v2/ThreadItem.ts b/src/app-server/v2/ThreadItem.ts index a4453855..df1a5dbe 100644 --- a/src/app-server/v2/ThreadItem.ts +++ b/src/app-server/v2/ThreadItem.ts @@ -5,6 +5,7 @@ import type { ImageGenerationItem } from "../ImageGenerationItem"; import type { LegacyAppPathString } from "../LegacyAppPathString"; import type { MessagePhase } from "../MessagePhase"; import type { ReasoningEffort } from "../ReasoningEffort"; +import type { SleepItem } from "../SleepItem"; import type { WebSearchItem } from "../WebSearchItem"; import type { JsonValue } from "../serde_json/JsonValue"; import type { CollabAgentState } from "./CollabAgentState"; @@ -105,4 +106,4 @@ reasoningEffort: ReasoningEffort | null, /** * Last known status of the target agents, when available. */ -agentsStates: { [key in string]?: CollabAgentState }, } | { "type": "subAgentActivity", id: string, kind: SubAgentActivityKind, agentThreadId: string, agentPath: string, } | { "type": "webSearch" } & WebSearchItem | { "type": "imageView", id: string, path: LegacyAppPathString, } | { "type": "sleep", id: string, durationMs: number, } | { "type": "imageGeneration" } & ImageGenerationItem | { "type": "enteredReviewMode", id: string, review: string, } | { "type": "exitedReviewMode", id: string, review: string, } | { "type": "contextCompaction", id: string, }; +agentsStates: { [key in string]?: CollabAgentState }, } | { "type": "subAgentActivity", id: string, kind: SubAgentActivityKind, agentThreadId: string, agentPath: string, } | { "type": "webSearch" } & WebSearchItem | { "type": "imageView", id: string, path: LegacyAppPathString, } | { "type": "sleep" } & SleepItem | { "type": "imageGeneration" } & ImageGenerationItem | { "type": "enteredReviewMode", id: string, review: string, } | { "type": "exitedReviewMode", id: string, review: string, } | { "type": "contextCompaction", id: string, }; diff --git a/src/app-server/v2/ThreadItemEntry.ts b/src/app-server/v2/ThreadItemEntry.ts new file mode 100644 index 00000000..c59564f2 --- /dev/null +++ b/src/app-server/v2/ThreadItemEntry.ts @@ -0,0 +1,10 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ThreadItem } from "./ThreadItem"; + +export type ThreadItemEntry = { +/** + * Turn containing this item. + */ +turnId: string, item: ThreadItem, }; diff --git a/src/app-server/v2/ThreadRealtimeInitialItem.ts b/src/app-server/v2/ThreadRealtimeInitialItem.ts new file mode 100644 index 00000000..6801b94f --- /dev/null +++ b/src/app-server/v2/ThreadRealtimeInitialItem.ts @@ -0,0 +1,9 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ConversationTextRole } from "../ConversationTextRole"; + +/** + * EXPERIMENTAL - role-bearing text item included when a realtime V3 session starts. + */ +export type ThreadRealtimeInitialItem = { role: ConversationTextRole, text: string, }; diff --git a/src/app-server/v2/TokenUsageBreakdown.ts b/src/app-server/v2/TokenUsageBreakdown.ts index 1d4e408f..dbb1b1fb 100644 --- a/src/app-server/v2/TokenUsageBreakdown.ts +++ b/src/app-server/v2/TokenUsageBreakdown.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type TokenUsageBreakdown = { totalTokens: number, inputTokens: number, cachedInputTokens: number, outputTokens: number, reasoningOutputTokens: number, }; +export type TokenUsageBreakdown = { totalTokens: number, inputTokens: number, cachedInputTokens: number, cacheWriteInputTokens: number, outputTokens: number, reasoningOutputTokens: number, }; diff --git a/src/app-server/v2/TurnEnvironmentParams.ts b/src/app-server/v2/TurnEnvironmentParams.ts index cb93ba39..f51fcf33 100644 --- a/src/app-server/v2/TurnEnvironmentParams.ts +++ b/src/app-server/v2/TurnEnvironmentParams.ts @@ -3,4 +3,8 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { LegacyAppPathString } from "../LegacyAppPathString"; -export type TurnEnvironmentParams = { environmentId: string, cwd: LegacyAppPathString, }; +export type TurnEnvironmentParams = { environmentId: string, cwd: LegacyAppPathString, +/** + * Environment-native runtime workspace roots. Omitted defaults to `cwd`. + */ +runtimeWorkspaceRoots?: Array | null, }; diff --git a/src/app-server/v2/UserInput.ts b/src/app-server/v2/UserInput.ts index 2ac37c52..c268cb4f 100644 --- a/src/app-server/v2/UserInput.ts +++ b/src/app-server/v2/UserInput.ts @@ -8,4 +8,4 @@ export type UserInput = { "type": "text", text: string, /** * UI-defined spans within `text` used to render or persist special elements. */ -text_elements: Array, } | { "type": "image", detail?: ImageDetail, url: string, } | { "type": "localImage", detail?: ImageDetail, path: string, } | { "type": "skill", name: string, path: string, } | { "type": "mention", name: string, path: string, }; +text_elements: Array, } | { "type": "image", detail?: ImageDetail, url: string, } | { "type": "localImage", detail?: ImageDetail, path: string, } | { "type": "audio", url: string, } | { "type": "localAudio", path: string, } | { "type": "skill", name: string, path: string, } | { "type": "mention", name: string, path: string, }; diff --git a/src/app-server/v2/index.ts b/src/app-server/v2/index.ts index ab1d684c..ccfc604f 100644 --- a/src/app-server/v2/index.ts +++ b/src/app-server/v2/index.ts @@ -29,13 +29,18 @@ export type { AppSummary } from "./AppSummary"; export type { AppTemplateSummary } from "./AppTemplateSummary"; export type { AppTemplateUnavailableReason } from "./AppTemplateUnavailableReason"; export type { AppToolApproval } from "./AppToolApproval"; +export type { AppToolSummary } from "./AppToolSummary"; export type { AppToolsConfig } from "./AppToolsConfig"; export type { ApprovalDecision } from "./ApprovalDecision"; export type { ApprovalsReviewer } from "./ApprovalsReviewer"; export type { AppsConfig } from "./AppsConfig"; export type { AppsDefaultConfig } from "./AppsDefaultConfig"; +export type { AppsInstalledParams } from "./AppsInstalledParams"; +export type { AppsInstalledResponse } from "./AppsInstalledResponse"; export type { AppsListParams } from "./AppsListParams"; export type { AppsListResponse } from "./AppsListResponse"; +export type { AppsReadParams } from "./AppsReadParams"; +export type { AppsReadResponse } from "./AppsReadResponse"; export type { AskForApproval } from "./AskForApproval"; export type { AttestationGenerateParams } from "./AttestationGenerateParams"; export type { AttestationGenerateResponse } from "./AttestationGenerateResponse"; @@ -93,6 +98,7 @@ export type { ConfigWarningNotification } from "./ConfigWarningNotification"; export type { ConfigWriteResponse } from "./ConfigWriteResponse"; export type { ConfiguredHookHandler } from "./ConfiguredHookHandler"; export type { ConfiguredHookMatcherGroup } from "./ConfiguredHookMatcherGroup"; +export type { ConnectorMetadata } from "./ConnectorMetadata"; export type { ConsumeAccountRateLimitResetCreditOutcome } from "./ConsumeAccountRateLimitResetCreditOutcome"; export type { ConsumeAccountRateLimitResetCreditParams } from "./ConsumeAccountRateLimitResetCreditParams"; export type { ConsumeAccountRateLimitResetCreditResponse } from "./ConsumeAccountRateLimitResetCreditResponse"; @@ -117,6 +123,7 @@ export type { DynamicToolFunctionSpec } from "./DynamicToolFunctionSpec"; export type { DynamicToolNamespaceSpec } from "./DynamicToolNamespaceSpec"; export type { DynamicToolNamespaceTool } from "./DynamicToolNamespaceTool"; export type { DynamicToolSpec } from "./DynamicToolSpec"; +export type { EnvironmentConnectionNotification } from "./EnvironmentConnectionNotification"; export type { ErrorNotification } from "./ErrorNotification"; export type { ExecPolicyAmendment } from "./ExecPolicyAmendment"; export type { ExperimentalFeature } from "./ExperimentalFeature"; @@ -138,6 +145,8 @@ export type { ExternalAgentConfigImportResponse } from "./ExternalAgentConfigImp export type { ExternalAgentConfigImportTypeResult } from "./ExternalAgentConfigImportTypeResult"; export type { ExternalAgentConfigMigrationItem } from "./ExternalAgentConfigMigrationItem"; export type { ExternalAgentConfigMigrationItemType } from "./ExternalAgentConfigMigrationItemType"; +export type { ExternalAgentImportedConnectorCandidate } from "./ExternalAgentImportedConnectorCandidate"; +export type { ExternalAgentImportedConnectorSource } from "./ExternalAgentImportedConnectorSource"; export type { FeedbackUploadParams } from "./FeedbackUploadParams"; export type { FeedbackUploadResponse } from "./FeedbackUploadResponse"; export type { FileChangeApprovalDecision } from "./FileChangeApprovalDecision"; @@ -206,6 +215,7 @@ export type { HookTrustStatus } from "./HookTrustStatus"; export type { HooksListEntry } from "./HooksListEntry"; export type { HooksListParams } from "./HooksListParams"; export type { HooksListResponse } from "./HooksListResponse"; +export type { InstalledApp } from "./InstalledApp"; export type { ItemCompletedNotification } from "./ItemCompletedNotification"; export type { ItemGuardianApprovalReviewCompletedNotification } from "./ItemGuardianApprovalReviewCompletedNotification"; export type { ItemGuardianApprovalReviewStartedNotification } from "./ItemGuardianApprovalReviewStartedNotification"; @@ -373,6 +383,7 @@ export type { RateLimitResetCreditsSummary } from "./RateLimitResetCreditsSummar export type { RateLimitResetType } from "./RateLimitResetType"; export type { RateLimitSnapshot } from "./RateLimitSnapshot"; export type { RateLimitWindow } from "./RateLimitWindow"; +export type { RawResponseCompletedNotification } from "./RawResponseCompletedNotification"; export type { RawResponseItemCompletedNotification } from "./RawResponseItemCompletedNotification"; export type { ReadOnlyAccess } from "./ReadOnlyAccess"; export type { ReasoningEffortOption } from "./ReasoningEffortOption"; @@ -396,6 +407,9 @@ export type { SandboxCommandAssessment } from "./SandboxCommandAssessment"; export type { SandboxMode } from "./SandboxMode"; export type { SandboxPolicy } from "./SandboxPolicy"; export type { SandboxWorkspaceWrite } from "./SandboxWorkspaceWrite"; +export type { ScheduledTaskSchedule } from "./ScheduledTaskSchedule"; +export type { ScheduledTaskSummary } from "./ScheduledTaskSummary"; +export type { ScheduledTaskWeekday } from "./ScheduledTaskWeekday"; export type { SelectedCapabilityRoot } from "./SelectedCapabilityRoot"; export type { SendAddCreditsNudgeEmailParams } from "./SendAddCreditsNudgeEmailParams"; export type { SendAddCreditsNudgeEmailResponse } from "./SendAddCreditsNudgeEmailResponse"; @@ -466,6 +480,7 @@ export type { ThreadHistoryMode } from "./ThreadHistoryMode"; export type { ThreadInjectItemsParams } from "./ThreadInjectItemsParams"; export type { ThreadInjectItemsResponse } from "./ThreadInjectItemsResponse"; export type { ThreadItem } from "./ThreadItem"; +export type { ThreadItemEntry } from "./ThreadItemEntry"; export type { ThreadListParams } from "./ThreadListParams"; export type { ThreadListResponse } from "./ThreadListResponse"; export type { ThreadLoadedListParams } from "./ThreadLoadedListParams"; @@ -479,6 +494,7 @@ export type { ThreadReadResponse } from "./ThreadReadResponse"; export type { ThreadRealtimeAudioChunk } from "./ThreadRealtimeAudioChunk"; export type { ThreadRealtimeClosedNotification } from "./ThreadRealtimeClosedNotification"; export type { ThreadRealtimeErrorNotification } from "./ThreadRealtimeErrorNotification"; +export type { ThreadRealtimeInitialItem } from "./ThreadRealtimeInitialItem"; export type { ThreadRealtimeItemAddedNotification } from "./ThreadRealtimeItemAddedNotification"; export type { ThreadRealtimeOutputAudioDeltaNotification } from "./ThreadRealtimeOutputAudioDeltaNotification"; export type { ThreadRealtimeSdpNotification } from "./ThreadRealtimeSdpNotification"; From c3c5062cd6b40ac9c82610829be6bbd122986e72 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Wed, 22 Jul 2026 10:10:42 +0200 Subject: [PATCH 13/25] Update dependencies (#325) Brings in the latest ACP schema + SDK, as well as updating other dev dependencies to address some security warnings. --- package-lock.json | 615 ++++++++++++++++++++++++++++++++++++++-------- package.json | 6 +- 2 files changed, 519 insertions(+), 102 deletions(-) diff --git a/package-lock.json b/package-lock.json index 597e5362..58e9e8eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "1.1.5", "license": "Apache-2.0", "dependencies": { - "@agentclientprotocol/sdk": "^1.2.1", + "@agentclientprotocol/sdk": "^1.3.0", "@openai/codex": "^0.145.0", "diff": "^9.0.0", "open": "^11.0.0", @@ -23,15 +23,15 @@ "@types/node": "^26.1.0", "esbuild": "^0.28.1", "mcp-hello-world": "^1.1.2", - "tsx": "^4.23.0", - "typescript": "^6.0.3", + "tsx": "^4.23.1", + "typescript": "^7.0.2", "vitest": "^4.1.10" } }, "node_modules/@agentclientprotocol/sdk": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-1.2.1.tgz", - "integrity": "sha512-jwYUdOQR7tc+Zfch53VL4JJyUNK/46q03uUTYb+PjECsmnNl94XFXOfYLJ8RBpMNidXd1rpOAVgb0vqD98xImA==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-1.3.0.tgz", + "integrity": "sha512-i3h/efaeuMUFAO1HSfo97QZQnnvMd7wWBYtBsdL6UMZg3a78sk3Ffya5Xu7C7tYsXomXoDXJBAzQF2PcFKAhIQ==", "license": "Apache-2.0", "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" @@ -1153,6 +1153,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1170,6 +1173,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1187,6 +1193,9 @@ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1204,6 +1213,9 @@ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1221,6 +1233,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1238,6 +1253,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1377,6 +1395,346 @@ "undici-types": "~8.3.0" } }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/@vitest/expect": { "version": "4.1.10", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", @@ -1886,9 +2244,9 @@ } }, "node_modules/es-module-lexer": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz", - "integrity": "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", "dev": true, "license": "MIT" }, @@ -2055,12 +2413,13 @@ } }, "node_modules/express-rate-limit": { - "version": "8.5.2", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", - "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.0.tgz", + "integrity": "sha512-XKJXDsASUOo0LLtFwW5hCcQGH0N4WQc/Rn8/Pvoia+TJFOkkFPvrtW9lZOeeNcxQJspvOIERMwiRLsVFlhHEkA==", "dev": true, "license": "MIT", "dependencies": { + "debug": "^4.4.3", "ip-address": "^10.2.0" }, "engines": { @@ -2073,6 +2432,31 @@ "express": ">= 4.11" } }, + "node_modules/express-rate-limit/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/express-rate-limit/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2081,9 +2465,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", - "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", "dev": true, "funding": [ { @@ -2258,9 +2642,9 @@ } }, "node_modules/hono": { - "version": "4.12.28", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.28.tgz", - "integrity": "sha512-YwUvVpSF7m1yOblFPrU3Hbo8XhPheBoiyfGuII6z19LnOr6JpDnyyp7LFNrfV56wS8tpvtBFGRISHN02pDdLOA==", + "version": "4.12.31", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.31.tgz", + "integrity": "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==", "dev": true, "license": "MIT", "engines": { @@ -2403,9 +2787,9 @@ "license": "ISC" }, "node_modules/jose": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", - "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.4.tgz", + "integrity": "sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==", "dev": true, "license": "MIT", "funding": { @@ -2427,9 +2811,9 @@ "license": "BSD-2-Clause" }, "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", "dev": true, "license": "MPL-2.0", "dependencies": { @@ -2443,23 +2827,23 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" } }, "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", "cpu": [ "arm64" ], @@ -2478,9 +2862,9 @@ } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", "cpu": [ "arm64" ], @@ -2499,9 +2883,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", "cpu": [ "x64" ], @@ -2520,9 +2904,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", "cpu": [ "x64" ], @@ -2541,9 +2925,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", "cpu": [ "arm" ], @@ -2562,13 +2946,16 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2583,13 +2970,16 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2604,13 +2994,16 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2625,13 +3018,16 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2646,9 +3042,9 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", "cpu": [ "arm64" ], @@ -2667,9 +3063,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", "cpu": [ "x64" ], @@ -2810,9 +3206,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.15", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", - "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "dev": true, "funding": [ { @@ -2862,9 +3258,9 @@ } }, "node_modules/obug": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", - "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", "dev": true, "funding": [ "https://github.com/sponsors/sxzz", @@ -2983,9 +3379,9 @@ } }, "node_modules/postcss": { - "version": "8.5.16", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", - "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "version": "8.5.21", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.21.tgz", + "integrity": "sha512-v4sDNP3fdNiWMfabO7OwOQdOX8TiQSztKyT1Wj0w+j7LDallJThJRBBBmzVGyYj0crMh7jlV4zepPkiNu9UwDQ==", "dev": true, "funding": [ { @@ -3003,7 +3399,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -3098,9 +3494,9 @@ } }, "node_modules/react": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", - "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", "dev": true, "license": "MIT", "peer": true, @@ -3109,9 +3505,9 @@ } }, "node_modules/react-dom": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", - "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", "dev": true, "license": "MIT", "peer": true, @@ -3119,7 +3515,7 @@ "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.7" + "react": "^19.2.8" } }, "node_modules/require-from-string": { @@ -3525,9 +3921,9 @@ "optional": true }, "node_modules/tsx": { - "version": "4.23.0", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.0.tgz", - "integrity": "sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==", + "version": "4.23.1", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", + "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3558,17 +3954,38 @@ } }, "node_modules/typescript": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", - "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", "dev": true, "license": "Apache-2.0", "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" + "tsc": "bin/tsc" }, "engines": { - "node": ">=14.17" + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" } }, "node_modules/undici-types": { @@ -3609,16 +4026,16 @@ } }, "node_modules/vite": { - "version": "8.1.4", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz", - "integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==", + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.5", - "postcss": "^8.5.16", - "rolldown": "~1.1.4", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", "tinyglobby": "^0.2.17" }, "bin": { diff --git a/package.json b/package.json index e299b070..010026f8 100644 --- a/package.json +++ b/package.json @@ -58,12 +58,12 @@ "@types/node": "^26.1.0", "esbuild": "^0.28.1", "mcp-hello-world": "^1.1.2", - "tsx": "^4.23.0", - "typescript": "^6.0.3", + "tsx": "^4.23.1", + "typescript": "^7.0.2", "vitest": "^4.1.10" }, "dependencies": { - "@agentclientprotocol/sdk": "^1.2.1", + "@agentclientprotocol/sdk": "^1.3.0", "@openai/codex": "^0.145.0", "diff": "^9.0.0", "open": "^11.0.0", From a2a63ee053fe6cf81d2f10698acca814ff02d20e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 22 Jul 2026 12:45:20 +0000 Subject: [PATCH 14/25] Release v1.1.6 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 58e9e8eb..9f886253 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@agentclientprotocol/codex-acp", - "version": "1.1.5", + "version": "1.1.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@agentclientprotocol/codex-acp", - "version": "1.1.5", + "version": "1.1.6", "license": "Apache-2.0", "dependencies": { "@agentclientprotocol/sdk": "^1.3.0", diff --git a/package.json b/package.json index 010026f8..2f6f3a8e 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "publishConfig": { "access": "public" }, - "version": "1.1.5", + "version": "1.1.6", "description": "", "main": "dist/index.js", "bin": { From bae159c91d01d43af4d6e00b425592f028d25ade Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Wed, 22 Jul 2026 15:55:10 +0200 Subject: [PATCH 15/25] fix: Emit plan contents in plan mode (#326) Closes #307 --- src/CodexAcpServer.ts | 16 +- src/CodexEventHandler.ts | 39 +++- ...ession-response-item-history-fallback.json | 8 +- .../data/plan-checklist-update.json | 23 +++ .../data/plan-completed-fallback.json | 21 +++ .../data/plan-delta-fallback.json | 21 +++ .../CodexACPAgent/data/plan-deltas.json | 21 +++ .../CodexACPAgent/plan-events.test.ts | 176 ++++++++++++++++++ 8 files changed, 313 insertions(+), 12 deletions(-) create mode 100644 src/__tests__/CodexACPAgent/data/plan-checklist-update.json create mode 100644 src/__tests__/CodexACPAgent/data/plan-completed-fallback.json create mode 100644 src/__tests__/CodexACPAgent/data/plan-delta-fallback.json create mode 100644 src/__tests__/CodexACPAgent/data/plan-deltas.json create mode 100644 src/__tests__/CodexACPAgent/plan-events.test.ts diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index d911342b..928f2b16 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -1469,7 +1469,7 @@ export class CodexAcpServer { case "contextCompaction": return [createCompletedContextCompactionUpdate(item)]; case "plan": - return [this.createPlanUpdate(item)]; + return [this.createPlanMessageUpdate(item)]; } } @@ -1520,16 +1520,14 @@ export class CodexAcpServer { }; } - private createPlanUpdate( + private createPlanMessageUpdate( item: ThreadItem & { type: "plan" } ): UpdateSessionEvent { - return { - sessionUpdate: "agent_message_chunk", - content: { - type: "text", - text: `Plan:\n${item.text}`, - }, - }; + return createAgentTextMessageChunk( + item.text, + item.id, + createCodexMessagePhaseMeta("final_answer"), + ); } private userInputToContentBlocks(input: UserInput): acp.ContentBlock[] { diff --git a/src/CodexEventHandler.ts b/src/CodexEventHandler.ts index 5739e44e..89e91eb5 100644 --- a/src/CodexEventHandler.ts +++ b/src/CodexEventHandler.ts @@ -19,6 +19,7 @@ import type { ItemStartedNotification, ThreadItem, ModelReroutedNotification, + PlanDeltaNotification, ReasoningSummaryPartAddedNotification, ReasoningSummaryTextDeltaNotification, ReasoningTextDeltaNotification, @@ -76,6 +77,7 @@ export class CodexEventHandler { private readonly activeGuardianApprovalReviews = new Set(); private readonly activeImageGenerationItems = new Set(); private readonly emittedImageViewItems = new Set(); + private readonly planDeltaTextByItemId = new Map(); private readonly seenReasoningDeltaItemIds = new Set(); private readonly terminalCommandIds = new Set(); private readonly terminalCommandOutputIds = new Set(); @@ -110,6 +112,8 @@ export class CodexEventHandler { switch (notification.method) { case "item/agentMessage/delta": return await this.createTextEvent(notification.params); + case "item/plan/delta": + return this.createPlanDeltaEvent(notification.params); case "item/started": return await this.createItemEvent(notification.params); case "item/completed": @@ -223,7 +227,6 @@ export class CodexEventHandler { case "rawResponseItem/completed": case "rawResponse/completed": case "thread/started": - case "item/plan/delta": case "remoteControl/status/changed": case "app/list/updated": case "thread/settings/updated": @@ -293,6 +296,15 @@ export class CodexEventHandler { return this.createAgentThoughtEvent(event.delta, event.itemId); } + private createPlanDeltaEvent(event: PlanDeltaNotification): UpdateSessionEvent | null { + if (event.delta.length === 0) { + return null; + } + const text = this.planDeltaTextByItemId.get(event.itemId) ?? ""; + this.planDeltaTextByItemId.set(event.itemId, text + event.delta); + return null; + } + private createReasoningSectionBreakEvent(event: ReasoningSummaryPartAddedNotification): UpdateSessionEvent { this.seenReasoningDeltaItemIds.add(event.itemId); return this.createAgentThoughtEvent("\n\n", event.itemId); @@ -389,6 +401,11 @@ export class CodexEventHandler { case "agentMessage": this.rememberAgentMessagePhase(event.item); return null; + case "plan": { + const deltaText = this.planDeltaTextByItemId.get(event.item.id) ?? ""; + this.planDeltaTextByItemId.delete(event.item.id); + return this.createCompletedPlanEvent(event.item, deltaText); + } case "exitedReviewMode": return this.createExitedReviewModeEvent(event.item); case "contextCompaction": @@ -404,7 +421,6 @@ export class CodexEventHandler { case "userMessage": case "hookPrompt": case "enteredReviewMode": - case "plan": return null; } @@ -423,6 +439,25 @@ export class CodexEventHandler { return this.createAgentThoughtEvent(text, item.id); } + private createCompletedPlanEvent( + item: ThreadItem & { type: "plan" }, + deltaText: string, + ): UpdateSessionEvent | null { + const text = item.text.length > 0 ? item.text : deltaText; + if (text.length === 0) { + return null; + } + return this.createPlanTextEvent(text, item.id); + } + + private createPlanTextEvent(text: string, messageId: string): UpdateSessionEvent { + return createAgentTextMessageChunk( + text, + messageId, + createCodexMessagePhaseMeta("final_answer"), + ); + } + private createExitedReviewModeEvent(item: ThreadItem & { type: "exitedReviewMode" }): UpdateSessionEvent | null { const text = item.review.trim(); if (text.length === 0) { 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 b314d869..a7420a6c 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 @@ -151,9 +151,15 @@ "sessionId": "session-legacy", "update": { "sessionUpdate": "agent_message_chunk", + "messageId": "item-plan-1", "content": { "type": "text", - "text": "Plan:\nInspect project files" + "text": "Inspect project files" + }, + "_meta": { + "codex": { + "phase": "final_answer" + } } } } diff --git a/src/__tests__/CodexACPAgent/data/plan-checklist-update.json b/src/__tests__/CodexACPAgent/data/plan-checklist-update.json new file mode 100644 index 00000000..ab0367f1 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/plan-checklist-update.json @@ -0,0 +1,23 @@ +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "test-session-id", + "update": { + "sessionUpdate": "plan", + "entries": [ + { + "status": "completed", + "content": "Add the event mapping", + "priority": "medium" + }, + { + "status": "in_progress", + "content": "Verify it in Zed", + "priority": "medium" + } + ] + } + } + ] +} \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/plan-completed-fallback.json b/src/__tests__/CodexACPAgent/data/plan-completed-fallback.json new file mode 100644 index 00000000..4642c754 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/plan-completed-fallback.json @@ -0,0 +1,21 @@ +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "test-session-id", + "update": { + "sessionUpdate": "agent_message_chunk", + "messageId": "plan-2", + "content": { + "type": "text", + "text": "### Fallback plan\n\n1. Use the completed item." + }, + "_meta": { + "codex": { + "phase": "final_answer" + } + } + } + } + ] +} \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/plan-delta-fallback.json b/src/__tests__/CodexACPAgent/data/plan-delta-fallback.json new file mode 100644 index 00000000..87a37988 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/plan-delta-fallback.json @@ -0,0 +1,21 @@ +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "test-session-id", + "update": { + "sessionUpdate": "agent_message_chunk", + "messageId": "plan-2", + "content": { + "type": "text", + "text": "### Buffered plan\n\n1. Use the buffered fallback." + }, + "_meta": { + "codex": { + "phase": "final_answer" + } + } + } + } + ] +} \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/plan-deltas.json b/src/__tests__/CodexACPAgent/data/plan-deltas.json new file mode 100644 index 00000000..e2036870 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/plan-deltas.json @@ -0,0 +1,21 @@ +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "test-session-id", + "update": { + "sessionUpdate": "agent_message_chunk", + "messageId": "plan-1", + "content": { + "type": "text", + "text": "Completed text should not duplicate the streamed plan." + }, + "_meta": { + "codex": { + "phase": "final_answer" + } + } + } + } + ] +} \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/plan-events.test.ts b/src/__tests__/CodexACPAgent/plan-events.test.ts new file mode 100644 index 00000000..67ca0a92 --- /dev/null +++ b/src/__tests__/CodexACPAgent/plan-events.test.ts @@ -0,0 +1,176 @@ +import {beforeEach, describe, expect, it, vi} from "vitest"; +import type {ServerNotification} from "../../app-server"; +import {AgentMode} from "../../AgentMode"; +import type {SessionState} from "../../CodexAcpServer"; +import { + createCodexMockTestFixture, + createTestSessionState, + setupPromptAndSendNotifications, + type CodexMockTestFixture, +} from "../acp-test-utils"; + +describe("CodexEventHandler - plan events", () => { + let mockFixture: CodexMockTestFixture; + const sessionId = "test-session-id"; + + beforeEach(() => { + mockFixture = createCodexMockTestFixture(); + vi.clearAllMocks(); + }); + + const sessionState: SessionState = createTestSessionState({ + sessionId, + currentModelId: "model-id[effort]", + agentMode: AgentMode.DEFAULT_AGENT_MODE, + }); + + it("emits the authoritative completed plan after buffering deltas", async () => { + const notifications: ServerNotification[] = [ + { + method: "item/started", + params: { + threadId: sessionId, + turnId: "turn-1", + startedAtMs: 0, + item: { + type: "plan", + id: "plan-1", + text: "", + }, + }, + }, + { + method: "item/plan/delta", + params: { + threadId: sessionId, + turnId: "turn-1", + itemId: "plan-1", + delta: "### Implementation plan\n\n", + }, + }, + { + method: "item/plan/delta", + params: { + threadId: sessionId, + turnId: "turn-1", + itemId: "plan-1", + delta: "1. Add the event mapping.\n2. Verify it.", + }, + }, + { + method: "item/completed", + params: { + threadId: sessionId, + turnId: "turn-1", + completedAtMs: 0, + item: { + type: "plan", + id: "plan-1", + text: "Completed text should not duplicate the streamed plan.", + }, + }, + }, + ]; + + await setupPromptAndSendNotifications(mockFixture, sessionId, sessionState, notifications); + + await expect(mockFixture.getAcpConnectionDump([])).toMatchFileSnapshot( + "data/plan-deltas.json", + ); + }); + + it("falls back to buffered deltas when the completed plan is empty", async () => { + const notifications: ServerNotification[] = [ + { + method: "item/plan/delta", + params: { + threadId: sessionId, + turnId: "turn-1", + itemId: "plan-2", + delta: "### Buffered plan\n\n", + }, + }, + { + method: "item/plan/delta", + params: { + threadId: sessionId, + turnId: "turn-1", + itemId: "plan-2", + delta: "1. Use the buffered fallback.", + }, + }, + { + method: "item/completed", + params: { + threadId: sessionId, + turnId: "turn-1", + completedAtMs: 0, + item: { + type: "plan", + id: "plan-2", + text: "", + }, + }, + }, + ]; + + await setupPromptAndSendNotifications(mockFixture, sessionId, sessionState, notifications); + + await expect(mockFixture.getAcpConnectionDump([])).toMatchFileSnapshot( + "data/plan-delta-fallback.json", + ); + }); + + it("emits the completed plan when no deltas streamed", async () => { + const notifications: ServerNotification[] = [ + { + method: "item/completed", + params: { + threadId: sessionId, + turnId: "turn-1", + completedAtMs: 0, + item: { + type: "plan", + id: "plan-2", + text: "### Fallback plan\n\n1. Use the completed item.", + }, + }, + }, + ]; + + await setupPromptAndSendNotifications(mockFixture, sessionId, sessionState, notifications); + + await expect(mockFixture.getAcpConnectionDump([])).toMatchFileSnapshot( + "data/plan-completed-fallback.json", + ); + }); + + it("keeps turn plan updates as ACP checklist updates", async () => { + const notifications: ServerNotification[] = [ + { + method: "turn/plan/updated", + params: { + threadId: sessionId, + turnId: "turn-1", + explanation: "Implement and verify the mapping.", + plan: [ + { + step: "Add the event mapping", + status: "completed", + }, + { + step: "Verify it in Zed", + status: "inProgress", + }, + ], + }, + }, + ]; + + await setupPromptAndSendNotifications(mockFixture, sessionId, sessionState, notifications); + + await expect(mockFixture.getAcpConnectionDump([])).toMatchFileSnapshot( + "data/plan-checklist-update.json", + ); + }); +}); From 39dd16a76a5a49c745dbc37bda2ab9110f2759e4 Mon Sep 17 00:00:00 2001 From: Mark Tkachenko Date: Wed, 22 Jul 2026 16:47:15 +0200 Subject: [PATCH 16/25] fix E2E (#330) --- .../e2e/acp-e2e-models-availability.test.ts | 21 +++++++++++++++---- .../e2e/spawned-agent-fixture.ts | 2 +- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/src/__tests__/CodexACPAgent/e2e/acp-e2e-models-availability.test.ts b/src/__tests__/CodexACPAgent/e2e/acp-e2e-models-availability.test.ts index e6555e0c..2d9b734a 100644 --- a/src/__tests__/CodexACPAgent/e2e/acp-e2e-models-availability.test.ts +++ b/src/__tests__/CodexACPAgent/e2e/acp-e2e-models-availability.test.ts @@ -2,8 +2,6 @@ import {afterEach, beforeEach, expect, it} from "vitest"; import {createAuthenticatedFixture, describeE2E, type SpawnedAgentFixture,} from "./acp-e2e-test-utils"; import {ModelId} from "../../../ModelId"; -const DEFAULT_MODEL_ID = ModelId.create("gpt-5.4-mini", "medium") - describeE2E("Models availability", () => { let fixture: SpawnedAgentFixture; @@ -17,7 +15,22 @@ describeE2E("Models availability", () => { it(`default model is available`, async () => { const session = await fixture.createSession(); - const models = session.models?.availableModels?.map(m => m.modelId); - expect(models).toContain(DEFAULT_MODEL_ID.toString()) + const models = session.models; + const availableModelIds = models?.availableModels?.map(m => m.modelId) ?? []; + expect(availableModelIds.length).toBeGreaterThan(0); + + // Codex's advertised catalog changes as it is upgraded, so assert the + // invariant that survives those bumps instead of pinning a specific + // model id: the session's current (default) model must be one of the + // advertised models. Compare on the base model id because + // availableModels enumerate model x reasoning-effort pairs while the + // current model may carry an effort (e.g. "none") that is not itself + // enumerated. + const currentModelId = models?.currentModelId; + expect(currentModelId).toBeDefined(); + + const currentBaseModel = ModelId.fromString(currentModelId!).model; + const availableBaseModels = availableModelIds.map(id => ModelId.fromString(id).model); + expect(availableBaseModels).toContain(currentBaseModel); }); }); diff --git a/src/__tests__/CodexACPAgent/e2e/spawned-agent-fixture.ts b/src/__tests__/CodexACPAgent/e2e/spawned-agent-fixture.ts index 32fbbdc3..d4edc609 100644 --- a/src/__tests__/CodexACPAgent/e2e/spawned-agent-fixture.ts +++ b/src/__tests__/CodexACPAgent/e2e/spawned-agent-fixture.ts @@ -10,7 +10,7 @@ import type {PermissionResponder} from "./permission-responders"; import type {LegacyNewSessionResponse} from "../../../AcpExtensions"; export const DEFAULT_TEST_MODEL_ID = ModelId.create("gpt-5.2", "none"); -export const OTHER_TEST_MODEL_ID = ModelId.create("gpt-5.4-mini", "low"); +export const OTHER_TEST_MODEL_ID = ModelId.create("gpt-5.5", "low"); export interface TestSkill { readonly name: string; From 307d81018f7cc0c3141ddf71c7532d38310e2cfb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 22 Jul 2026 14:51:55 +0000 Subject: [PATCH 17/25] Release v1.1.7 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9f886253..3847268a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@agentclientprotocol/codex-acp", - "version": "1.1.6", + "version": "1.1.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@agentclientprotocol/codex-acp", - "version": "1.1.6", + "version": "1.1.7", "license": "Apache-2.0", "dependencies": { "@agentclientprotocol/sdk": "^1.3.0", diff --git a/package.json b/package.json index 2f6f3a8e..fcdaf98f 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "publishConfig": { "access": "public" }, - "version": "1.1.6", + "version": "1.1.7", "description": "", "main": "dist/index.js", "bin": { From ba5bef59cfcea4229841fe9438d816696621307b Mon Sep 17 00:00:00 2001 From: Mark Tkachenko Date: Fri, 24 Jul 2026 11:47:44 +0200 Subject: [PATCH 18/25] Models availability e2e test fix - new default model gpt-5.6-sol/medium (#335) --- .../e2e/acp-e2e-models-availability.test.ts | 21 ++++--------------- 1 file changed, 4 insertions(+), 17 deletions(-) diff --git a/src/__tests__/CodexACPAgent/e2e/acp-e2e-models-availability.test.ts b/src/__tests__/CodexACPAgent/e2e/acp-e2e-models-availability.test.ts index 2d9b734a..b1b69b75 100644 --- a/src/__tests__/CodexACPAgent/e2e/acp-e2e-models-availability.test.ts +++ b/src/__tests__/CodexACPAgent/e2e/acp-e2e-models-availability.test.ts @@ -2,6 +2,8 @@ import {afterEach, beforeEach, expect, it} from "vitest"; import {createAuthenticatedFixture, describeE2E, type SpawnedAgentFixture,} from "./acp-e2e-test-utils"; import {ModelId} from "../../../ModelId"; +const DEFAULT_MODEL_ID = ModelId.create("gpt-5.6-sol", "medium") + describeE2E("Models availability", () => { let fixture: SpawnedAgentFixture; @@ -15,22 +17,7 @@ describeE2E("Models availability", () => { it(`default model is available`, async () => { const session = await fixture.createSession(); - const models = session.models; - const availableModelIds = models?.availableModels?.map(m => m.modelId) ?? []; - expect(availableModelIds.length).toBeGreaterThan(0); - - // Codex's advertised catalog changes as it is upgraded, so assert the - // invariant that survives those bumps instead of pinning a specific - // model id: the session's current (default) model must be one of the - // advertised models. Compare on the base model id because - // availableModels enumerate model x reasoning-effort pairs while the - // current model may carry an effort (e.g. "none") that is not itself - // enumerated. - const currentModelId = models?.currentModelId; - expect(currentModelId).toBeDefined(); - - const currentBaseModel = ModelId.fromString(currentModelId!).model; - const availableBaseModels = availableModelIds.map(id => ModelId.fromString(id).model); - expect(availableBaseModels).toContain(currentBaseModel); + const models = session.models?.availableModels?.map(m => m.modelId); + expect(models).toContain(DEFAULT_MODEL_ID.toString()) }); }); From f41dcf16ff87cea6f5e0807926bf53cc618ee2c2 Mon Sep 17 00:00:00 2001 From: nikita-ashihmin Date: Thu, 30 Jul 2026 20:20:24 +0400 Subject: [PATCH 19/25] Expose structured permission changes in ACP metadata (#342) --- src/CodexApprovalHandler.ts | 159 +++++++++++++++++- .../CodexACPAgent/approval-events.test.ts | 82 +++++++++ .../data/approval-permissions-request.json | 122 ++++++++++++++ 3 files changed, 362 insertions(+), 1 deletion(-) diff --git a/src/CodexApprovalHandler.ts b/src/CodexApprovalHandler.ts index dd73b8ae..7c2e08a9 100644 --- a/src/CodexApprovalHandler.ts +++ b/src/CodexApprovalHandler.ts @@ -29,17 +29,28 @@ type FileChangeDecisionOption = { decision: FileChangeApprovalDecision; }; +type PermissionMetadata = { + version: 1; + changes: Array>; +}; + function permissionOption( optionId: string, name: string, kind: acp.PermissionOptionKind, codexMeta?: Record, + permission?: PermissionMetadata, ): acp.PermissionOption { return { optionId, name, kind, - ...(codexMeta ? { _meta: { codex: codexMeta } } : {}), + ...((codexMeta || permission) ? { + _meta: { + ...(permission ? {permission} : {}), + ...(codexMeta ? {codex: codexMeta} : {}), + }, + } : {}), }; } @@ -175,12 +186,14 @@ export class CodexApprovalHandler implements ApprovalHandler { "Allow for Session", "allow_always", { decision: "allowPermissionsForSession", permissions: params.permissions }, + this.permissionGrantMetadata(params.permissions, "session"), ), permissionOption( ApprovalOptionId.AllowPermissionsForTurn, "Allow Once", "allow_once", { decision: "allowPermissionsForTurn", permissions: params.permissions }, + this.permissionGrantMetadata(params.permissions, "turn"), ), permissionOption( ApprovalOptionId.RejectPermissions, @@ -265,6 +278,23 @@ export class CodexApprovalHandler implements ApprovalHandler { : "Allow for Session", "allow_always", { decision: "acceptForSession" }, + params.networkApprovalContext ? { + version: 1, + changes: [{ + type: "grant", + operation: "grant", + description: `Allow access to ${params.networkApprovalContext.host} for this session`, + lifetime: {scope: "session"}, + targets: [{ + type: "network", + matcher: { + type: "host", + host: params.networkApprovalContext.host, + protocol: params.networkApprovalContext.protocol, + }, + }], + }], + } : undefined, ), decision: "acceptForSession", }, @@ -280,6 +310,22 @@ export class CodexApprovalHandler implements ApprovalHandler { decision: "acceptWithExecpolicyAmendment", execpolicyAmendment: params.proposedExecpolicyAmendment, }, + { + version: 1, + changes: [{ + type: "policy_rule", + operation: "add", + ruleBehavior: "allow", + description: `Allow commands starting with ${params.proposedExecpolicyAmendment.join(" ")}`, + targets: [{ + type: "command", + matcher: { + type: "argv_prefix", + argv: params.proposedExecpolicyAmendment, + }, + }], + }], + }, ), decision: { acceptWithExecpolicyAmendment: { @@ -299,6 +345,24 @@ export class CodexApprovalHandler implements ApprovalHandler { decision: "applyNetworkPolicyAmendment", networkPolicyAmendment: amendment, }, + { + version: 1, + changes: [{ + type: "policy_rule", + operation: "add", + ruleBehavior: amendment.action, + description: amendment.action === "allow" + ? `Allow access to ${amendment.host}` + : `Block access to ${amendment.host}`, + targets: [{ + type: "network", + matcher: { + type: "host", + host: amendment.host, + }, + }], + }], + }, ), decision: { applyNetworkPolicyAmendment: { @@ -328,6 +392,20 @@ export class CodexApprovalHandler implements ApprovalHandler { params.grantRoot ? "Allow Root for Session" : "Allow for Session", "allow_always", { decision: "acceptForSession", grantRoot: params.grantRoot ?? null }, + params.grantRoot ? { + version: 1, + changes: [{ + type: "grant", + operation: "grant", + description: `Allow writes under ${params.grantRoot} for this session`, + lifetime: {scope: "session"}, + targets: [{ + type: "filesystem", + access: ["write"], + matcher: {type: "directory", path: params.grantRoot}, + }], + }], + } : undefined, ), decision: "acceptForSession", }, @@ -353,6 +431,85 @@ export class CodexApprovalHandler implements ApprovalHandler { }; } + private permissionGrantMetadata( + permissions: RequestPermissionProfile, + scope: "turn" | "session", + ): PermissionMetadata | undefined { + const changes: Array> = []; + const lifetime = {scope}; + const suffix = scope === "session" ? " for this session" : " for this turn"; + + if (permissions.network?.enabled !== null && permissions.network?.enabled !== undefined) { + const allowed = permissions.network.enabled; + changes.push({ + type: allowed ? "grant" : "policy_rule", + operation: allowed ? "grant" : "add", + ...(allowed ? {} : {ruleBehavior: "deny"}), + description: `${allowed ? "Allow" : "Deny"} network access${suffix}`, + lifetime, + targets: [{type: "network", matcher: {type: "any"}}], + }); + } + + const fileSystem = permissions.fileSystem; + for (const path of fileSystem?.read ?? []) { + changes.push(this.fileSystemGrantChange(path, "read", lifetime, suffix)); + } + for (const path of fileSystem?.write ?? []) { + changes.push(this.fileSystemGrantChange(path, "write", lifetime, suffix)); + } + for (const entry of fileSystem?.entries ?? []) { + const matcher = (() => { + switch (entry.path.type) { + case "path": + return {type: "exact_path", path: entry.path.path}; + case "glob_pattern": + return {type: "glob", pattern: entry.path.pattern}; + case "special": + return {type: "special", provider: "codex", value: entry.path.value}; + } + })(); + const pathDescription = entry.path.type === "path" + ? entry.path.path + : entry.path.type === "glob_pattern" ? entry.path.pattern : JSON.stringify(entry.path.value); + changes.push({ + type: entry.access === "deny" ? "policy_rule" : "grant", + operation: entry.access === "deny" ? "add" : "grant", + ...(entry.access === "deny" ? {ruleBehavior: "deny"} : {}), + description: entry.access === "deny" + ? `Deny filesystem access to ${pathDescription}${suffix}` + : `Allow ${entry.access} access to ${pathDescription}${suffix}`, + lifetime, + targets: [{ + type: "filesystem", + ...(entry.access === "deny" ? {} : {access: [entry.access]}), + matcher, + }], + }); + } + + return changes.length > 0 ? {version: 1, changes} : undefined; + } + + private fileSystemGrantChange( + path: string, + access: "read" | "write", + lifetime: {scope: "turn" | "session"}, + suffix: string, + ): Record { + return { + type: "grant", + operation: "grant", + description: `Allow ${access} access to ${path}${suffix}`, + lifetime, + targets: [{ + type: "filesystem", + access: [access], + matcher: {type: "exact_path", path}, + }], + }; + } + private networkPolicyAmendmentOptionId(index: number): string { return `${ApprovalOptionId.ApplyNetworkPolicyAmendment}:${index}`; } diff --git a/src/__tests__/CodexACPAgent/approval-events.test.ts b/src/__tests__/CodexACPAgent/approval-events.test.ts index 205a3802..821f23bc 100644 --- a/src/__tests__/CodexACPAgent/approval-events.test.ts +++ b/src/__tests__/CodexACPAgent/approval-events.test.ts @@ -155,6 +155,27 @@ describe('Approval Events', () => { expect.objectContaining({ optionId: ApprovalOptionId.AcceptWithExecpolicyAmendment, kind: 'allow_always', + _meta: { + permission: { + version: 1, + changes: [{ + type: 'policy_rule', + operation: 'add', + ruleBehavior: 'allow', + description: 'Allow commands starting with npm install', + targets: [{ + type: 'command', + matcher: { + type: 'argv_prefix', + argv: proposedExecpolicyAmendment, + }, + }], + }], + }, + codex: expect.objectContaining({ + execpolicyAmendment: proposedExecpolicyAmendment, + }), + }, }) ); @@ -201,6 +222,27 @@ describe('Approval Events', () => { expect.objectContaining({ optionId, kind: 'allow_always', + _meta: { + permission: { + version: 1, + changes: [{ + type: 'policy_rule', + operation: 'add', + ruleBehavior: 'allow', + description: 'Allow access to registry.npmjs.org', + targets: [{ + type: 'network', + matcher: { + type: 'host', + host: 'registry.npmjs.org', + }, + }], + }], + }, + codex: expect.objectContaining({ + networkPolicyAmendment, + }), + }, }) ); @@ -389,6 +431,46 @@ describe('Approval Events', () => { await promptPromise; }); + it('should describe a session write-root grant with common permission metadata', async () => { + const { promptPromise, completeTurn } = setupSessionWithPendingPrompt(); + fixture.setPermissionResponse({ + outcome: { outcome: 'selected', optionId: ApprovalOptionId.AllowAlways } + }); + + const params: FileChangeRequestApprovalParams = { + threadId: sessionId, + turnId: 'turn-1', + startedAtMs: 0, + itemId: 'file-change-grant-root', + reason: 'Write generated files', + grantRoot: '/workspace/generated', + }; + + await fixture.sendServerRequest('item/fileChange/requestApproval', params); + + const request = fixture.getAcpConnectionEvents([])[0]!.args[0]; + expect(request.options.find((option: { optionId: string }) => option.optionId === ApprovalOptionId.AllowAlways)?._meta) + .toMatchObject({ + permission: { + version: 1, + changes: [{ + type: 'grant', + operation: 'grant', + description: 'Allow writes under /workspace/generated for this session', + lifetime: {scope: 'session'}, + targets: [{ + type: 'filesystem', + access: ['write'], + matcher: {type: 'directory', path: '/workspace/generated'}, + }], + }], + }, + }); + + completeTurn(); + await promptPromise; + }); + it('should return cancel when no handler registered', async () => { const params: FileChangeRequestApprovalParams = { threadId: 'non-existent-session', diff --git a/src/__tests__/CodexACPAgent/data/approval-permissions-request.json b/src/__tests__/CodexACPAgent/data/approval-permissions-request.json index 99ce7a14..c5bf196b 100644 --- a/src/__tests__/CodexACPAgent/data/approval-permissions-request.json +++ b/src/__tests__/CodexACPAgent/data/approval-permissions-request.json @@ -47,6 +47,67 @@ "name": "Allow for Session", "kind": "allow_always", "_meta": { + "permission": { + "version": 1, + "changes": [ + { + "type": "grant", + "operation": "grant", + "description": "Allow network access for this session", + "lifetime": { + "scope": "session" + }, + "targets": [ + { + "type": "network", + "matcher": { + "type": "any" + } + } + ] + }, + { + "type": "grant", + "operation": "grant", + "description": "Allow read access to /home/user/project for this session", + "lifetime": { + "scope": "session" + }, + "targets": [ + { + "type": "filesystem", + "access": [ + "read" + ], + "matcher": { + "type": "exact_path", + "path": "/home/user/project" + } + } + ] + }, + { + "type": "grant", + "operation": "grant", + "description": "Allow write access to /home/user/project/tmp for this session", + "lifetime": { + "scope": "session" + }, + "targets": [ + { + "type": "filesystem", + "access": [ + "write" + ], + "matcher": { + "type": "exact_path", + "path": "/home/user/project/tmp" + } + } + ] + } + ] + }, "codex": { "decision": "allowPermissionsForSession", "permissions": { @@ -71,6 +132,67 @@ "name": "Allow Once", "kind": "allow_once", "_meta": { + "permission": { + "version": 1, + "changes": [ + { + "type": "grant", + "operation": "grant", + "description": "Allow network access for this turn", + "lifetime": { + "scope": "turn" + }, + "targets": [ + { + "type": "network", + "matcher": { + "type": "any" + } + } + ] + }, + { + "type": "grant", + "operation": "grant", + "description": "Allow read access to /home/user/project for this turn", + "lifetime": { + "scope": "turn" + }, + "targets": [ + { + "type": "filesystem", + "access": [ + "read" + ], + "matcher": { + "type": "exact_path", + "path": "/home/user/project" + } + } + ] + }, + { + "type": "grant", + "operation": "grant", + "description": "Allow write access to /home/user/project/tmp for this turn", + "lifetime": { + "scope": "turn" + }, + "targets": [ + { + "type": "filesystem", + "access": [ + "write" + ], + "matcher": { + "type": "exact_path", + "path": "/home/user/project/tmp" + } + } + ] + } + ] + }, "codex": { "decision": "allowPermissionsForTurn", "permissions": { From 75ae3cf9ae28ac507b64480e3dc902295e126dd6 Mon Sep 17 00:00:00 2001 From: nikita-ashihmin Date: Sun, 2 Aug 2026 01:18:36 +0400 Subject: [PATCH 20/25] Add ACP plan review confirmation flow (#351) --- src/CodexAcpServer.ts | 165 +++++++++++++- src/CodexEventHandler.ts | 43 +++- src/PlanCapabilities.ts | 7 + .../CodexACPAgent/plan-review-events.test.ts | 202 ++++++++++++++++++ 4 files changed, 408 insertions(+), 9 deletions(-) create mode 100644 src/PlanCapabilities.ts create mode 100644 src/__tests__/CodexACPAgent/plan-review-events.test.ts diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index 928f2b16..f8f8561b 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -1,6 +1,6 @@ import * as acp from "@agentclientprotocol/sdk"; import {RequestError, type SessionId, type SessionModeState} from "@agentclientprotocol/sdk"; -import {CodexEventHandler} from "./CodexEventHandler"; +import {CodexEventHandler, type CompletedPlan} from "./CodexEventHandler"; import {CodexApprovalHandler} from "./CodexApprovalHandler"; import {CodexElicitationHandler} from "./CodexElicitationHandler"; import {type CodexAuthRequest, getCodexAuthMethods, isCodexAuthRequest} from "./CodexAuthMethod"; @@ -22,7 +22,9 @@ import {AgentMode, MODE_CONFIG_ID} from "./AgentMode"; import { COLLABORATION_MODE_CONFIG_ID, createCollaborationModeConfigOption, + DEFAULT_COLLABORATION_MODE, parseCollaborationMode, + PLAN_COLLABORATION_MODE, } from "./CollaborationModeConfig"; import type {ModeKind} from "./app-server/ModeKind"; import { @@ -79,6 +81,7 @@ import { import packageJson from "../package.json"; import {isJetBrains2026_1Client} from "./JBUtils"; import {resolveTerminalOutputMode, type TerminalOutputMode} from "./TerminalOutputMode"; +import {clientSupportsPlanUpdates} from "./PlanCapabilities"; import { createCodexMessagePhaseMeta, createAgentTextMessageChunk, @@ -91,6 +94,9 @@ import { toThreadGoalSnapshot, } from "./ThreadGoalSnapshot"; +const IMPLEMENT_PLAN_OPTION_ID = "implement_plan"; +const REVISE_PLAN_OPTION_ID = "revise_plan"; + export interface SessionState { sessionId: string, currentModelId: string, @@ -1469,7 +1475,7 @@ export class CodexAcpServer { case "contextCompaction": return [createCompletedContextCompactionUpdate(item)]; case "plan": - return [this.createPlanMessageUpdate(item)]; + return item.text.length > 0 ? [this.createPlanHistoryUpdate(item)] : []; } } @@ -1520,9 +1526,19 @@ export class CodexAcpServer { }; } - private createPlanMessageUpdate( + private createPlanHistoryUpdate( item: ThreadItem & { type: "plan" } ): UpdateSessionEvent { + if (clientSupportsPlanUpdates(this.clientCapabilities)) { + return { + sessionUpdate: "plan_update", + plan: { + type: "markdown", + planId: item.id, + content: item.text, + }, + }; + } return createAgentTextMessageChunk( item.text, item.id, @@ -1876,7 +1892,11 @@ export class CodexAcpServer { const disposePromptRequestCancellation = this.observePromptRequestCancellation(signal, sessionState, activePrompt); try { - const eventHandler = new CodexEventHandler(this.connection, sessionState); + const eventHandler = new CodexEventHandler( + this.connection, + sessionState, + clientSupportsPlanUpdates(this.clientCapabilities), + ); const approvalHandler = new CodexApprovalHandler(this.connection, sessionState, activePrompt.signal); const elicitationHandler = new CodexElicitationHandler( this.connection, @@ -2008,7 +2028,7 @@ export class CodexAcpServer { logger.error(`Prompt for cancelled session ${params.sessionId} failed after prompt returned`, err); } }); - const turnCompleted = await Promise.race([ + let turnCompleted = await Promise.race([ sendPromptPromise, activePrompt.closeSignal, this.cancelBeforeTurnStarted(activePrompt), @@ -2031,6 +2051,82 @@ export class CodexAcpServer { throw error; } + const completedPlan = eventHandler.takeCompletedPlan(); + if ( + completedPlan !== null + && sessionState.collaborationMode === PLAN_COLLABORATION_MODE + && !this.promptShouldStop(params.sessionId, activePrompt) + ) { + const approved = await this.requestPlanImplementationPermission( + sessionState, + completedPlan, + activePrompt.signal, + ); + if (this.promptShouldStop(params.sessionId, activePrompt)) { + return this.cancelledPromptResponse(sessionState); + } + if (approved && !this.promptShouldStop(params.sessionId, activePrompt)) { + await this.applyCollaborationModeChange(sessionState, DEFAULT_COLLABORATION_MODE); + const session = new ACPSessionConnection(this.connection, sessionState.sessionId); + await session.update({ + sessionUpdate: "config_option_update", + configOptions: this.createSessionConfigOptions(sessionState), + }); + + const implementationRequest: acp.PromptRequest = { + sessionId: params.sessionId, + prompt: [{type: "text", text: "Implement the approved plan."}], + }; + activePrompt.currentTurn = null; + const implementationPromise = this.runWithProcessCheck( + () => this.codexAcpClient.sendPrompt( + implementationRequest, + agentMode, + modelId, + serviceTier, + disableSummary, + sessionState.cwd, + sessionState.additionalDirectories, + (turnId) => { + const turn = {threadId: params.sessionId, turnId}; + activePrompt.currentTurn = turn; + if (this.promptShouldStop(params.sessionId, activePrompt)) { + this.interruptLateStartedTurn(turn); + return; + } + sessionState.currentTurnId = turnId; + }, + () => this.promptShouldStop(params.sessionId, activePrompt), + ), + ); + void implementationPromise.catch((err) => { + if (this.activePrompts.get(params.sessionId) !== activePrompt) { + logger.error(`Implementation turn for cancelled prompt ${params.sessionId} failed after prompt returned`, err); + } + }); + turnCompleted = await Promise.race([ + implementationPromise, + activePrompt.closeSignal, + this.cancelBeforeTurnStarted(activePrompt), + ]); + + if (turnCompleted === null) { + return this.cancelledPromptResponse(sessionState); + } + + await this.codexAcpClient.waitForSessionNotifications(params.sessionId); + if (turnCompleted.turn.status === "interrupted") { + await this.notifyConversationInterrupted(params.sessionId); + return this.cancelledPromptResponse(sessionState); + } + + const implementationError = eventHandler.getFailure(); + if (implementationError) { + throw implementationError; + } + } + } + await this.publishFallbackSessionTitle( sessionState, this.createPromptFallbackTitle(params.prompt), @@ -2057,6 +2153,65 @@ export class CodexAcpServer { } } + private async requestPlanImplementationPermission( + sessionState: SessionState, + plan: CompletedPlan, + cancellationSignal: AbortSignal, + ): Promise { + const toolCallId = `plan-review:${plan.itemId}`; + try { + const response = await this.connection.request( + acp.methods.client.session.requestPermission, + { + sessionId: sessionState.sessionId, + toolCall: { + toolCallId, + title: "Implement this plan?", + kind: "switch_mode", + status: "pending", + rawInput: {plan: plan.text}, + }, + options: [ + { + optionId: IMPLEMENT_PLAN_OPTION_ID, + name: "Yes, implement this plan", + kind: "allow_once", + }, + { + optionId: REVISE_PLAN_OPTION_ID, + name: "No, and tell Codex what to do differently", + kind: "reject_once", + }, + ], + _meta: { + codex: { + kind: "plan_review", + planItemId: plan.itemId, + }, + }, + }, + {cancellationSignal}, + ); + const approved = response.outcome.outcome === "selected" + && response.outcome.optionId === IMPLEMENT_PLAN_OPTION_ID; + await this.connection.notify(acp.methods.client.session.update, { + sessionId: sessionState.sessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId, + status: "completed", + rawOutput: approved + ? "User approved the plan." + : "User kept the session in plan mode.", + }, + }); + return approved; + } catch (error) { + logger.error("Error requesting plan implementation permission", error); + return false; + } + } + private cancelledPromptResponse(sessionState: SessionState): acp.PromptResponse { return { stopReason: "cancelled", diff --git a/src/CodexEventHandler.ts b/src/CodexEventHandler.ts index 89e91eb5..2a06ddff 100644 --- a/src/CodexEventHandler.ts +++ b/src/CodexEventHandler.ts @@ -68,11 +68,18 @@ import {sameThreadGoalSnapshot, toThreadGoalSnapshot} from "./ThreadGoalSnapshot export { stripShellPrefix }; +export type CompletedPlan = { + itemId: string; + text: string; +}; + export class CodexEventHandler { private readonly connection: AcpClientConnection; private readonly sessionState: SessionState; + private readonly supportsPlanUpdates: boolean; private failure: RequestError | null = null; + private completedPlan: CompletedPlan | null = null; private readonly activeFuzzyFileSearchSessions = new Set(); private readonly activeGuardianApprovalReviews = new Set(); private readonly activeImageGenerationItems = new Set(); @@ -84,15 +91,26 @@ export class CodexEventHandler { private readonly agentMessagePhases = new Map(); private readonly activeSubAgentActivities = new Set(); - constructor(connection: AcpClientConnection, sessionState: SessionState) { + constructor( + connection: AcpClientConnection, + sessionState: SessionState, + supportsPlanUpdates = false, + ) { this.connection = connection; this.sessionState = sessionState; + this.supportsPlanUpdates = supportsPlanUpdates; } getFailure(): RequestError | null { return this.failure; } + takeCompletedPlan(): CompletedPlan | null { + const plan = this.completedPlan; + this.completedPlan = null; + return plan; + } + async handleNotification(notification: ServerNotification) { const session = new ACPSessionConnection(this.connection, this.sessionState.sessionId); const updateEvent = await this.createUpdateEvent(notification); @@ -301,8 +319,11 @@ export class CodexEventHandler { return null; } const text = this.planDeltaTextByItemId.get(event.itemId) ?? ""; - this.planDeltaTextByItemId.set(event.itemId, text + event.delta); - return null; + const updatedText = text + event.delta; + this.planDeltaTextByItemId.set(event.itemId, updatedText); + return this.supportsPlanUpdates + ? this.createPlanUpdateEvent(updatedText, event.itemId) + : null; } private createReasoningSectionBreakEvent(event: ReasoningSummaryPartAddedNotification): UpdateSessionEvent { @@ -447,7 +468,21 @@ export class CodexEventHandler { if (text.length === 0) { return null; } - return this.createPlanTextEvent(text, item.id); + this.completedPlan = {itemId: item.id, text}; + return this.supportsPlanUpdates + ? this.createPlanUpdateEvent(text, item.id) + : this.createPlanTextEvent(text, item.id); + } + + private createPlanUpdateEvent(text: string, planId: string): UpdateSessionEvent { + return { + sessionUpdate: "plan_update", + plan: { + type: "markdown", + planId, + content: text, + }, + }; } private createPlanTextEvent(text: string, messageId: string): UpdateSessionEvent { diff --git a/src/PlanCapabilities.ts b/src/PlanCapabilities.ts new file mode 100644 index 00000000..9f1903bc --- /dev/null +++ b/src/PlanCapabilities.ts @@ -0,0 +1,7 @@ +import type * as acp from "@agentclientprotocol/sdk"; + +export function clientSupportsPlanUpdates( + clientCapabilities?: acp.ClientCapabilities | null, +): boolean { + return clientCapabilities?.plan != null; +} diff --git a/src/__tests__/CodexACPAgent/plan-review-events.test.ts b/src/__tests__/CodexACPAgent/plan-review-events.test.ts new file mode 100644 index 00000000..088869cb --- /dev/null +++ b/src/__tests__/CodexACPAgent/plan-review-events.test.ts @@ -0,0 +1,202 @@ +import * as acp from "@agentclientprotocol/sdk"; +import {beforeEach, describe, expect, it, vi} from "vitest"; +import {PLAN_COLLABORATION_MODE} from "../../CollaborationModeConfig"; +import { + createCodexMockTestFixture, + createTestSessionState, + type CodexMockTestFixture, +} from "../acp-test-utils"; + +type TurnCompletion = { + threadId: string; + turn: { + id: string; + items: never[]; + itemsView: "notLoaded"; + status: "completed"; + error: null; + startedAt: null; + completedAt: null; + durationMs: null; + }; +}; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((promiseResolve) => { + resolve = promiseResolve; + }); + return {promise, resolve}; +} + +describe("CodexACPAgent - plan review", () => { + let fixture: CodexMockTestFixture; + const sessionId = "plan-review-session"; + + beforeEach(() => { + fixture = createCodexMockTestFixture(); + vi.clearAllMocks(); + }); + + async function startPlanPrompt(permissionOptionId: string | null) { + await fixture.getCodexAcpAgent().initialize({ + protocolVersion: acp.PROTOCOL_VERSION, + clientCapabilities: {plan: {}}, + }); + fixture.setPermissionResponse(permissionOptionId === null + ? {outcome: {outcome: "cancelled"}} + : {outcome: {outcome: "selected", optionId: permissionOptionId}}); + + const sessionState = createTestSessionState({ + sessionId, + collaborationMode: PLAN_COLLABORATION_MODE, + }); + vi.spyOn(fixture.getCodexAcpAgent(), "getSessionState").mockReturnValue(sessionState); + + const planTurn = deferred(); + const implementationTurn = deferred(); + const turnStart = vi.spyOn(fixture.getCodexAppServerClient(), "turnStart") + .mockResolvedValueOnce({ + turn: { + id: "plan-turn", + items: [], + itemsView: "notLoaded", + status: "inProgress", + error: null, + startedAt: null, + completedAt: null, + durationMs: null, + }, + }) + .mockResolvedValueOnce({ + turn: { + id: "implementation-turn", + items: [], + itemsView: "notLoaded", + status: "inProgress", + error: null, + startedAt: null, + completedAt: null, + durationMs: null, + }, + }); + vi.spyOn(fixture.getCodexAppServerClient(), "awaitTurnCompleted") + .mockImplementation((_threadId, turnId) => turnId === "plan-turn" + ? planTurn.promise + : implementationTurn.promise); + + const promptPromise = fixture.getCodexAcpAgent().prompt({ + sessionId, + prompt: [{type: "text", text: "Plan the change"}], + }); + await vi.waitFor(() => expect(turnStart).toHaveBeenCalledTimes(1)); + + fixture.sendServerNotification({ + method: "item/plan/delta", + params: { + threadId: sessionId, + turnId: "plan-turn", + itemId: "plan-item", + delta: "# Implementation plan\n\n1. Make the change.", + }, + }); + fixture.sendServerNotification({ + method: "item/completed", + params: { + threadId: sessionId, + turnId: "plan-turn", + completedAtMs: 0, + item: { + type: "plan", + id: "plan-item", + text: "# Implementation plan\n\n1. Make the change.", + }, + }, + }); + planTurn.resolve({ + threadId: sessionId, + turn: { + id: "plan-turn", + items: [], + itemsView: "notLoaded", + status: "completed", + error: null, + startedAt: null, + completedAt: null, + durationMs: null, + }, + }); + + return {promptPromise, sessionState, turnStart, implementationTurn}; + } + + it("requests plan permission and starts one implementation turn when approved", async () => { + const {promptPromise, sessionState, turnStart, implementationTurn} = await startPlanPrompt("implement_plan"); + + await vi.waitFor(() => expect(turnStart).toHaveBeenCalledTimes(2)); + expect(turnStart.mock.calls[1]![0]).toMatchObject({ + threadId: sessionId, + input: [{type: "text", text: "Implement the approved plan."}], + }); + + const events = fixture.getAcpConnectionEvents([]); + expect(events).toContainEqual({ + method: "requestPermission", + args: [expect.objectContaining({ + sessionId, + toolCall: expect.objectContaining({ + toolCallId: "plan-review:plan-item", + title: "Implement this plan?", + kind: "switch_mode", + rawInput: {plan: "# Implementation plan\n\n1. Make the change."}, + }), + options: [ + {optionId: "implement_plan", name: "Yes, implement this plan", kind: "allow_once"}, + {optionId: "revise_plan", name: "No, and tell Codex what to do differently", kind: "reject_once"}, + ], + })], + }); + expect(events).toContainEqual({ + method: "sessionUpdate", + args: [{ + sessionId, + update: { + sessionUpdate: "plan_update", + plan: { + type: "markdown", + planId: "plan-item", + content: "# Implementation plan\n\n1. Make the change.", + }, + }, + }], + }); + expect(sessionState.collaborationMode).toBe("default"); + + implementationTurn.resolve({ + threadId: sessionId, + turn: { + id: "implementation-turn", + items: [], + itemsView: "notLoaded", + status: "completed", + error: null, + startedAt: null, + completedAt: null, + durationMs: null, + }, + }); + await expect(promptPromise).resolves.toMatchObject({stopReason: "end_turn"}); + expect(turnStart).toHaveBeenCalledTimes(2); + }); + + it.each([ + ["revise_plan", "rejected"], + [null, "cancelled"], + ])("keeps plan mode and does not implement when review is %s", async (optionId, _description) => { + const {promptPromise, sessionState, turnStart} = await startPlanPrompt(optionId); + + await expect(promptPromise).resolves.toMatchObject({stopReason: "end_turn"}); + expect(turnStart).toHaveBeenCalledTimes(1); + expect(sessionState.collaborationMode).toBe(PLAN_COLLABORATION_MODE); + }); +}); From 308c882ed7b348730b53affc0710c620153b64e2 Mon Sep 17 00:00:00 2001 From: Sergey Ignatov Date: Sat, 1 Aug 2026 23:54:03 +0200 Subject: [PATCH 21/25] Fix flaky file approval e2e test (#352) --- src/__tests__/CodexACPAgent/e2e/acp-e2e-file-approval.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/__tests__/CodexACPAgent/e2e/acp-e2e-file-approval.test.ts b/src/__tests__/CodexACPAgent/e2e/acp-e2e-file-approval.test.ts index 51182971..6edc1956 100644 --- a/src/__tests__/CodexACPAgent/e2e/acp-e2e-file-approval.test.ts +++ b/src/__tests__/CodexACPAgent/e2e/acp-e2e-file-approval.test.ts @@ -35,7 +35,8 @@ describeE2E("E2E file approval tests", () => { it("does not apply rejected file edits", async () => { fixture.setPermissionResponder(createPermissionResponder("edit", ApprovalOptionId.RejectOnce)); const sessionId = await editFileDirectly(fixture, path.join(fixture.workspaceDir, generateFileNameForTest()), false); - expectPermissionRequests(fixture, sessionId, {edit: 1, execute: 0}); + expect(fixture.readPermissionRequests(sessionId, "edit").length).toBeGreaterThanOrEqual(1); + expect(fixture.readPermissionRequests(sessionId, "execute")).toHaveLength(0); }); }); From 6dbbc46587fdb198f686ffffa5903c924a0dc98b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 1 Aug 2026 22:01:44 +0000 Subject: [PATCH 22/25] Release v1.1.8 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3847268a..cbcfeb34 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@agentclientprotocol/codex-acp", - "version": "1.1.7", + "version": "1.1.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@agentclientprotocol/codex-acp", - "version": "1.1.7", + "version": "1.1.8", "license": "Apache-2.0", "dependencies": { "@agentclientprotocol/sdk": "^1.3.0", diff --git a/package.json b/package.json index fcdaf98f..ce90d742 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "publishConfig": { "access": "public" }, - "version": "1.1.7", + "version": "1.1.8", "description": "", "main": "dist/index.js", "bin": { From 662fdbbb64aea340ee1291c95e58de18c10609b7 Mon Sep 17 00:00:00 2001 From: Nikita Ashikhmin Date: Sun, 2 Aug 2026 11:15:09 +0400 Subject: [PATCH 23/25] Throttle ACP plan update snapshots --- src/CodexAcpServer.ts | 10 +- src/CodexEventHandler.ts | 106 +++++++++-- .../CodexACPAgent/plan-events.test.ts | 169 +++++++++++++++++- .../CodexACPAgent/plan-review-events.test.ts | 9 + 4 files changed, 276 insertions(+), 18 deletions(-) diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index f8f8561b..0ed48705 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -1890,13 +1890,15 @@ export class CodexAcpServer { return pendingTurnStart; }; const disposePromptRequestCancellation = this.observePromptRequestCancellation(signal, sessionState, activePrompt); + let eventHandler: CodexEventHandler | null = null; try { - const eventHandler = new CodexEventHandler( + const promptEventHandler = new CodexEventHandler( this.connection, sessionState, clientSupportsPlanUpdates(this.clientCapabilities), ); + eventHandler = promptEventHandler; const approvalHandler = new CodexApprovalHandler(this.connection, sessionState, activePrompt.signal); const elicitationHandler = new CodexElicitationHandler( this.connection, @@ -1907,7 +1909,7 @@ export class CodexAcpServer { await this.codexAcpClient.subscribeToSessionEvents(params.sessionId, async (event) => { await elicitationHandler.handleNotification(event); - return eventHandler.handleNotification(event); + return promptEventHandler.handleNotification(event); }, approvalHandler, elicitationHandler); @@ -2041,6 +2043,7 @@ export class CodexAcpServer { await this.codexAcpClient.waitForSessionNotifications(params.sessionId); if (turnCompleted.turn.status === "interrupted") { + await eventHandler.flushPendingPlanUpdates(); await this.notifyConversationInterrupted(params.sessionId); return this.cancelledPromptResponse(sessionState); } @@ -2051,6 +2054,7 @@ export class CodexAcpServer { throw error; } + await eventHandler.flushPendingPlanUpdates(); const completedPlan = eventHandler.takeCompletedPlan(); if ( completedPlan !== null @@ -2116,6 +2120,7 @@ export class CodexAcpServer { await this.codexAcpClient.waitForSessionNotifications(params.sessionId); if (turnCompleted.turn.status === "interrupted") { + await eventHandler.flushPendingPlanUpdates(); await this.notifyConversationInterrupted(params.sessionId); return this.cancelledPromptResponse(sessionState); } @@ -2142,6 +2147,7 @@ export class CodexAcpServer { throw err; } finally { logger.log("Prompt completed", {sessionId: params.sessionId}); + await eventHandler?.dispose(); disposePromptRequestCancellation(); sessionState.currentTurnId = null; const registeredPendingTurnStart = this.pendingTurnStarts.get(params.sessionId); diff --git a/src/CodexEventHandler.ts b/src/CodexEventHandler.ts index 2a06ddff..895c1c59 100644 --- a/src/CodexEventHandler.ts +++ b/src/CodexEventHandler.ts @@ -65,6 +65,7 @@ import { createAgentTextThoughtChunk, } from "./ContentChunks"; import {sameThreadGoalSnapshot, toThreadGoalSnapshot} from "./ThreadGoalSnapshot"; +import {logger} from "./Logger"; export { stripShellPrefix }; @@ -75,7 +76,8 @@ export type CompletedPlan = { export class CodexEventHandler { - private readonly connection: AcpClientConnection; + private static readonly PLAN_UPDATE_INTERVAL_MS = 150; + private readonly sessionState: SessionState; private readonly supportsPlanUpdates: boolean; private failure: RequestError | null = null; @@ -85,6 +87,12 @@ export class CodexEventHandler { private readonly activeImageGenerationItems = new Set(); private readonly emittedImageViewItems = new Set(); private readonly planDeltaTextByItemId = new Map(); + private readonly pendingPlanItemIds = new Set(); + private readonly lastEmittedPlanTextByItemId = new Map(); + private readonly session: ACPSessionConnection; + private planUpdateTimer: ReturnType | null = null; + private planUpdateChain: Promise = Promise.resolve(); + private disposed = false; private readonly seenReasoningDeltaItemIds = new Set(); private readonly terminalCommandIds = new Set(); private readonly terminalCommandOutputIds = new Set(); @@ -96,9 +104,9 @@ export class CodexEventHandler { sessionState: SessionState, supportsPlanUpdates = false, ) { - this.connection = connection; this.sessionState = sessionState; this.supportsPlanUpdates = supportsPlanUpdates; + this.session = new ACPSessionConnection(connection, sessionState.sessionId); } getFailure(): RequestError | null { @@ -112,13 +120,37 @@ export class CodexEventHandler { } async handleNotification(notification: ServerNotification) { - const session = new ACPSessionConnection(this.connection, this.sessionState.sessionId); const updateEvent = await this.createUpdateEvent(notification); if (updateEvent) { - await session.update(updateEvent); + await this.session.update(updateEvent); } } + async flushPendingPlanUpdates(): Promise { + this.cancelPlanUpdateTimer(); + do { + const itemIds = [...this.pendingPlanItemIds]; + this.pendingPlanItemIds.clear(); + await Promise.all(itemIds.map(itemId => { + const text = this.planDeltaTextByItemId.get(itemId) ?? ""; + return text.length > 0 + ? this.enqueuePlanSnapshot(itemId, text) + : Promise.resolve(); + })); + await this.planUpdateChain; + } while (this.pendingPlanItemIds.size > 0); + } + + async dispose(): Promise { + if (this.disposed) return; + await this.flushPendingPlanUpdates(); + this.disposed = true; + this.cancelPlanUpdateTimer(); + this.pendingPlanItemIds.clear(); + this.planDeltaTextByItemId.clear(); + this.lastEmittedPlanTextByItemId.clear(); + } + private async createUpdateEvent(notification: ServerNotification): Promise { /* TODO split UpdateSessionEvent to improve completion @@ -144,6 +176,8 @@ export class CodexEventHandler { this.sessionState.currentTurnId = notification.params.turn.id; return null; case "turn/completed": + await this.flushPendingPlanUpdates(); + this.clearPlanTurnState(); this.sessionState.currentTurnId = null; return null; case "thread/tokenUsage/updated": @@ -314,16 +348,18 @@ export class CodexEventHandler { return this.createAgentThoughtEvent(event.delta, event.itemId); } - private createPlanDeltaEvent(event: PlanDeltaNotification): UpdateSessionEvent | null { + private createPlanDeltaEvent(event: PlanDeltaNotification): null { if (event.delta.length === 0) { return null; } const text = this.planDeltaTextByItemId.get(event.itemId) ?? ""; const updatedText = text + event.delta; this.planDeltaTextByItemId.set(event.itemId, updatedText); - return this.supportsPlanUpdates - ? this.createPlanUpdateEvent(updatedText, event.itemId) - : null; + if (this.supportsPlanUpdates) { + this.pendingPlanItemIds.add(event.itemId); + this.schedulePlanUpdate(); + } + return null; } private createReasoningSectionBreakEvent(event: ReasoningSummaryPartAddedNotification): UpdateSessionEvent { @@ -424,8 +460,7 @@ export class CodexEventHandler { return null; case "plan": { const deltaText = this.planDeltaTextByItemId.get(event.item.id) ?? ""; - this.planDeltaTextByItemId.delete(event.item.id); - return this.createCompletedPlanEvent(event.item, deltaText); + return await this.createCompletedPlanEvent(event.item, deltaText); } case "exitedReviewMode": return this.createExitedReviewModeEvent(event.item); @@ -460,18 +495,59 @@ export class CodexEventHandler { return this.createAgentThoughtEvent(text, item.id); } - private createCompletedPlanEvent( + private async createCompletedPlanEvent( item: ThreadItem & { type: "plan" }, deltaText: string, - ): UpdateSessionEvent | null { + ): Promise { const text = item.text.length > 0 ? item.text : deltaText; + this.pendingPlanItemIds.delete(item.id); + if (this.pendingPlanItemIds.size === 0) { + this.cancelPlanUpdateTimer(); + } + this.planDeltaTextByItemId.delete(item.id); if (text.length === 0) { return null; } this.completedPlan = {itemId: item.id, text}; - return this.supportsPlanUpdates - ? this.createPlanUpdateEvent(text, item.id) - : this.createPlanTextEvent(text, item.id); + if (this.supportsPlanUpdates) { + await this.enqueuePlanSnapshot(item.id, text); + return null; + } + return this.createPlanTextEvent(text, item.id); + } + + private schedulePlanUpdate(): void { + if (this.disposed || this.planUpdateTimer !== null) return; + this.planUpdateTimer = setTimeout(() => { + this.planUpdateTimer = null; + void this.flushPendingPlanUpdates().catch(error => { + logger.error("Failed to flush throttled plan updates", error); + }); + }, CodexEventHandler.PLAN_UPDATE_INTERVAL_MS); + } + + private cancelPlanUpdateTimer(): void { + if (this.planUpdateTimer === null) return; + clearTimeout(this.planUpdateTimer); + this.planUpdateTimer = null; + } + + private enqueuePlanSnapshot(itemId: string, text: string): Promise { + const send = async () => { + if (this.lastEmittedPlanTextByItemId.get(itemId) === text) return; + await this.session.update(this.createPlanUpdateEvent(text, itemId)); + this.lastEmittedPlanTextByItemId.set(itemId, text); + }; + const result = this.planUpdateChain.then(send); + this.planUpdateChain = result.catch(() => {}); + return result; + } + + private clearPlanTurnState(): void { + this.cancelPlanUpdateTimer(); + this.pendingPlanItemIds.clear(); + this.planDeltaTextByItemId.clear(); + this.lastEmittedPlanTextByItemId.clear(); } private createPlanUpdateEvent(text: string, planId: string): UpdateSessionEvent { diff --git a/src/__tests__/CodexACPAgent/plan-events.test.ts b/src/__tests__/CodexACPAgent/plan-events.test.ts index 67ca0a92..aa22e89a 100644 --- a/src/__tests__/CodexACPAgent/plan-events.test.ts +++ b/src/__tests__/CodexACPAgent/plan-events.test.ts @@ -1,7 +1,9 @@ -import {beforeEach, describe, expect, it, vi} from "vitest"; +import {afterEach, beforeEach, describe, expect, it, vi} from "vitest"; import type {ServerNotification} from "../../app-server"; import {AgentMode} from "../../AgentMode"; import type {SessionState} from "../../CodexAcpServer"; +import {CodexEventHandler} from "../../CodexEventHandler"; +import type {AcpClientConnection} from "../../ACPSessionConnection"; import { createCodexMockTestFixture, createTestSessionState, @@ -18,6 +20,10 @@ describe("CodexEventHandler - plan events", () => { vi.clearAllMocks(); }); + afterEach(() => { + vi.useRealTimers(); + }); + const sessionState: SessionState = createTestSessionState({ sessionId, currentModelId: "model-id[effort]", @@ -173,4 +179,165 @@ describe("CodexEventHandler - plan events", () => { "data/plan-checklist-update.json", ); }); + + describe("plan update coalescing", () => { + function createHandler( + notify = vi.fn(async (_method: unknown, _params: unknown) => {}), + ) { + const connection = { + notify, + request: vi.fn(), + } as unknown as AcpClientConnection; + const handler = new CodexEventHandler(connection, sessionState, true); + const planUpdates = () => notify.mock.calls + .map(call => call[1] as {update?: {sessionUpdate?: string, plan?: {planId: string, content: string}}}) + .filter(params => params.update?.sessionUpdate === "plan_update") + .map(params => params.update!.plan!); + return {handler, planUpdates}; + } + + function planDelta(itemId: string, delta: string): ServerNotification { + return { + method: "item/plan/delta", + params: {threadId: sessionId, turnId: "turn-1", itemId, delta}, + }; + } + + function completedPlan(itemId: string, text: string): ServerNotification { + return { + method: "item/completed", + params: { + threadId: sessionId, + turnId: "turn-1", + completedAtMs: 0, + item: {type: "plan", id: itemId, text}, + }, + }; + } + + function completedTurn(status: "completed" | "interrupted"): ServerNotification { + return { + method: "turn/completed", + params: { + threadId: sessionId, + turn: { + id: "turn-1", + items: [], + itemsView: "notLoaded", + status, + error: null, + startedAt: null, + completedAt: null, + durationMs: null, + }, + }, + }; + } + + it("coalesces many small deltas and emits the complete final snapshot", async () => { + vi.useFakeTimers(); + const {handler, planUpdates} = createHandler(); + let fullText = ""; + + for (let index = 0; index < 200; index += 1) { + const delta = `${index % 10}`; + fullText += delta; + await handler.handleNotification(planDelta("plan-many", delta)); + if (index % 10 === 9) { + await vi.advanceTimersByTimeAsync(25); + } + } + await handler.handleNotification(completedPlan("plan-many", fullText)); + + expect(planUpdates().length).toBeLessThan(20); + expect(planUpdates().length).toBeGreaterThan(1); + expect(planUpdates().at(-1)).toEqual({type: "markdown", planId: "plan-many", content: fullText}); + await handler.dispose(); + }); + + it.each(["completed", "interrupted"] as const)("flushes a pending snapshot when the turn is %s", async status => { + vi.useFakeTimers(); + const {handler, planUpdates} = createHandler(); + await handler.handleNotification(planDelta("plan-boundary", "full pending plan")); + + await handler.handleNotification(completedTurn(status)); + + expect(planUpdates()).toEqual([{type: "markdown", planId: "plan-boundary", content: "full pending plan"}]); + await vi.advanceTimersByTimeAsync(1_000); + expect(planUpdates()).toHaveLength(1); + await handler.dispose(); + }); + + it("does not duplicate an identical completed snapshot", async () => { + vi.useFakeTimers(); + const {handler, planUpdates} = createHandler(); + await handler.handleNotification(planDelta("plan-same", "same text")); + await vi.advanceTimersByTimeAsync(150); + + await handler.handleNotification(completedPlan("plan-same", "same text")); + + expect(planUpdates()).toEqual([{type: "markdown", planId: "plan-same", content: "same text"}]); + await handler.dispose(); + }); + + it("serializes an in-flight throttled snapshot before the completed snapshot", async () => { + vi.useFakeTimers(); + let releaseFirstSend!: () => void; + let markFirstSendStarted!: () => void; + const firstSendStarted = new Promise(resolve => { + markFirstSendStarted = resolve; + }); + const firstSendReleased = new Promise(resolve => { + releaseFirstSend = resolve; + }); + let firstSend = true; + const notify = vi.fn(async (_method: unknown, _params: unknown) => { + if (!firstSend) return; + firstSend = false; + markFirstSendStarted(); + await firstSendReleased; + }); + const {handler, planUpdates} = createHandler(notify); + await handler.handleNotification(planDelta("plan-race", "partial")); + + await vi.advanceTimersByTimeAsync(150); + await firstSendStarted; + const completion = handler.handleNotification(completedPlan("plan-race", "partial and final")); + releaseFirstSend(); + await completion; + + expect(planUpdates().map(plan => plan.content)).toEqual(["partial", "partial and final"]); + await handler.dispose(); + }); + + it("flushes and cancels pending work when disposed", async () => { + vi.useFakeTimers(); + const {handler, planUpdates} = createHandler(); + await handler.handleNotification(planDelta("plan-dispose", "last session snapshot")); + + await handler.dispose(); + await vi.advanceTimersByTimeAsync(1_000); + + expect(planUpdates()).toEqual([ + {type: "markdown", planId: "plan-dispose", content: "last session snapshot"}, + ]); + }); + + it("keeps independently streamed plans separate", async () => { + vi.useFakeTimers(); + const {handler, planUpdates} = createHandler(); + await handler.handleNotification(planDelta("plan-a", "A1")); + await handler.handleNotification(planDelta("plan-b", "B1")); + await handler.handleNotification(planDelta("plan-a", "A2")); + await handler.handleNotification(planDelta("plan-b", "B2")); + + await handler.handleNotification(completedTurn("completed")); + + expect(planUpdates()).toEqual([ + {type: "markdown", planId: "plan-a", content: "A1A2"}, + {type: "markdown", planId: "plan-b", content: "B1B2"}, + ]); + await handler.dispose(); + }); + }); }); diff --git a/src/__tests__/CodexACPAgent/plan-review-events.test.ts b/src/__tests__/CodexACPAgent/plan-review-events.test.ts index 088869cb..c31d2d85 100644 --- a/src/__tests__/CodexACPAgent/plan-review-events.test.ts +++ b/src/__tests__/CodexACPAgent/plan-review-events.test.ts @@ -170,6 +170,15 @@ describe("CodexACPAgent - plan review", () => { }, }], }); + const finalPlanUpdateIndex = events.reduce((lastIndex, event, index) => + event.method === "sessionUpdate" + && (event.args[0] as {update?: {sessionUpdate?: string}}).update?.sessionUpdate === "plan_update" + ? index + : lastIndex, + -1); + const permissionIndex = events.findIndex(event => event.method === "requestPermission"); + expect(finalPlanUpdateIndex).toBeGreaterThanOrEqual(0); + expect(permissionIndex).toBeGreaterThan(finalPlanUpdateIndex); expect(sessionState.collaborationMode).toBe("default"); implementationTurn.resolve({ From 992299d7d54cfa2ee376a375a4447ed198f638ba Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 2 Aug 2026 09:19:01 +0000 Subject: [PATCH 24/25] Release v1.1.9 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index cbcfeb34..0cf880ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@agentclientprotocol/codex-acp", - "version": "1.1.8", + "version": "1.1.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@agentclientprotocol/codex-acp", - "version": "1.1.8", + "version": "1.1.9", "license": "Apache-2.0", "dependencies": { "@agentclientprotocol/sdk": "^1.3.0", diff --git a/package.json b/package.json index ce90d742..322205cf 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "publishConfig": { "access": "public" }, - "version": "1.1.8", + "version": "1.1.9", "description": "", "main": "dist/index.js", "bin": { From efa3789c3909838590f2f7cf24682ec4a0e987e4 Mon Sep 17 00:00:00 2001 From: Mark Tkachenko Date: Sun, 2 Aug 2026 17:19:23 +0200 Subject: [PATCH 25/25] fix: Stop emitting "Conversation interrupted" message (#358) --- src/CodexAcpServer.ts | 13 ------------- src/__tests__/CodexACPAgent/session-close.test.ts | 1 - 2 files changed, 14 deletions(-) diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index 0ed48705..f1fb9cec 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -1963,7 +1963,6 @@ export class CodexAcpServer { logger.log("Prompt handled by a command"); await this.codexAcpClient.waitForSessionNotifications(params.sessionId); if (commandResult.turnCompleted?.turn.status === "interrupted") { - await this.notifyConversationInterrupted(params.sessionId); return this.cancelledPromptResponse(sessionState); } const error = eventHandler.getFailure(); @@ -2044,7 +2043,6 @@ export class CodexAcpServer { if (turnCompleted.turn.status === "interrupted") { await eventHandler.flushPendingPlanUpdates(); - await this.notifyConversationInterrupted(params.sessionId); return this.cancelledPromptResponse(sessionState); } @@ -2121,7 +2119,6 @@ export class CodexAcpServer { await this.codexAcpClient.waitForSessionNotifications(params.sessionId); if (turnCompleted.turn.status === "interrupted") { await eventHandler.flushPendingPlanUpdates(); - await this.notifyConversationInterrupted(params.sessionId); return this.cancelledPromptResponse(sessionState); } @@ -2226,16 +2223,6 @@ export class CodexAcpServer { }; } - private async notifyConversationInterrupted(sessionId: string): Promise { - if (this.sessionIsClosing(sessionId) || !this.sessions.has(sessionId)) { - return; - } - await this.connection.notify(acp.methods.client.session.update, { - sessionId, - update: createAgentTextMessageChunk("*Conversation interrupted*"), - }); - } - private buildQuotaMeta(sessionState: SessionState): { quota: QuotaMeta } { const lastTokenUsage = sessionState.lastTokenUsage; diff --git a/src/__tests__/CodexACPAgent/session-close.test.ts b/src/__tests__/CodexACPAgent/session-close.test.ts index 5effd4b0..97fa73aa 100644 --- a/src/__tests__/CodexACPAgent/session-close.test.ts +++ b/src/__tests__/CodexACPAgent/session-close.test.ts @@ -86,7 +86,6 @@ describe("ACP session close", () => { const requestMethods = fixture.getCodexConnectionEvents([]) .flatMap(event => event.eventType === "request" ? [event.method] : []); expect(requestMethods).toEqual(["thread/unsubscribe"]); - expect(fixture.getAcpConnectionDump([])).not.toContain("Conversation interrupted"); expect(() => codexAcpAgent.getSessionState(sessionId)).toThrow(`Session ${sessionId} not found`); fixture.clearCodexConnectionDump();