From 3acb318042363389949f8ee716966ec5ca5e82a6 Mon Sep 17 00:00:00 2001 From: Nikita Ashikhmin Date: Sun, 13 Sep 2026 17:27:00 +0400 Subject: [PATCH 1/8] feat: support in-place session rewind Map the AIR session rewind extension to Codex thread/revert. Keep the same thread ID and avoid a provider fork. --- README.md | 1 + docs/session-rewind-extension.md | 56 ++++++++++++++ src/AcpExtensions.ts | 15 +++- src/AirExtension.ts | 1 + src/CodexAcpClient.ts | 5 ++ src/CodexAcpServer.ts | 8 ++ src/CodexAppServerClient.ts | 6 ++ src/SessionRewind.ts | 57 ++++++++++++++ .../CodexACPAgent/initialize.test.ts | 2 +- src/__tests__/SessionRewind.test.ts | 74 +++++++++++++++++++ src/index.ts | 14 ++++ 11 files changed, 237 insertions(+), 2 deletions(-) create mode 100644 docs/session-rewind-extension.md create mode 100644 src/SessionRewind.ts create mode 100644 src/__tests__/SessionRewind.test.ts diff --git a/README.md b/README.md index f43ad986b..856c55a5a 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ Use [OpenAI Codex](https://github.com/openai/codex) from [Agent Client Protocol] - [Native ACP subagent sessions](docs/subagent-sessions.md) (after capability negotiation) with separate child histories and root-routed permissions; a legacy tool-call fallback otherwise. - [Background terminal tasks](docs/async-tasks.md) in AIR, with task status and targeted stop support after capability negotiation. - Session-scoped long-running goals through the provider-neutral [goal extension](docs/goal-extension.md). +- In-place message editing through the AIR [session rewind extension](docs/session-rewind-extension.md), without a provider fork. - A per-turn [agent file-change report](docs/agent-file-change-report.md) after capability negotiation. - Client-provided MCP servers over command-based stdio config and HTTP transport. - Slash commands: `/status`, `/mcp`, `/skills`, `/goal`, `/review`, `/review-branch`, `/review-commit`, `/compact`, and `/logout`, as well as configured skills. diff --git a/docs/session-rewind-extension.md b/docs/session-rewind-extension.md new file mode 100644 index 000000000..b26b2c619 --- /dev/null +++ b/docs/session-rewind-extension.md @@ -0,0 +1,56 @@ +# Session rewind extension + +Standard ACP can fork a session, but it cannot remove a transcript suffix from the same provider session. The experimental AIR session rewind extension adds that operation without creating another session. + +## Capability negotiation + +The adapter advertises `sessionRewind` in its `initialize` response: + +```json +{ + "_meta": { + "jetbrains": { + "air": { + "version": 1, + "capabilities": ["sessionRewind"] + } + } + } +} +``` + +A client must send `_session/rewind` only when the adapter advertises this capability. The leading underscore identifies a method outside standard ACP. + +## Request and response + +The request names the current ACP session and the first user message to remove: + +```json +{ + "sessionId": "thread-1", + "beforeMessage": { + "messageId": "user-message-2", + "messageFingerprint": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "messageOccurrence": 1 + }, + "resumeAtMessage": { + "messageId": "assistant-message-1", + "messageFingerprint": "sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + "messageOccurrence": 1 + } +} +``` + +`beforeMessage` is excluded from the retained history. `resumeAtMessage` identifies the last visible assistant message to retain. It is absent when the client rewinds the first user turn. + +Each history point contains the ACP message ID, the SHA-256 fingerprint of its complete text, and the one-based occurrence of that fingerprint for its role. The adapter uses the message ID first. It uses the fingerprint occurrence when restored provider history has different message IDs. + +The adapter returns `{ "rewound": true }` only after Codex accepts the rewind. A false response or an error leaves the client transcript unchanged. + +## Codex mapping + +The adapter reads the existing Codex thread history and resolves `beforeMessage` to its containing turn. It then calls `thread/revert` with that turn as the exclusive boundary. + +The Codex thread ID remains the ACP session ID. The adapter does not call `thread/fork`, create a thread, or add a session-list entry. `resumeAtMessage` is not needed for this mapping because Codex reverts at a turn boundary. + +After a successful response, the client can remove the same transcript suffix and place the selected user text in its editor. diff --git a/src/AcpExtensions.ts b/src/AcpExtensions.ts index b450c8bd0..cca4297fd 100644 --- a/src/AcpExtensions.ts +++ b/src/AcpExtensions.ts @@ -15,6 +15,10 @@ import { ASYNC_TASK_STOP_METHOD, type AsyncTaskStopExtRequest, } from "./async-tasks/AsyncTaskExtension"; +import { + SESSION_REWIND_METHOD, + type SessionRewindRequest, +} from "./SessionRewind"; export { AUTH_STATUS_META_KEY, @@ -79,6 +83,7 @@ export type ExtMethodRequest = | SessionSteeringExtRequest | GoalControlExtRequest | AsyncTaskStopExtRequest + | SessionRewindExtRequest export function isExtMethodRequest(request: { method: string, params: Record }): request is ExtMethodRequest { return request.method === "authentication/status" @@ -87,7 +92,8 @@ export function isExtMethodRequest(request: { method: string, params: Record, params: SessionSteerRequest, diff --git a/src/AirExtension.ts b/src/AirExtension.ts index 28af8784c..1aaf90487 100644 --- a/src/AirExtension.ts +++ b/src/AirExtension.ts @@ -18,6 +18,7 @@ export const AIR_AGENT_FILE_CHANGE_REPORT_KEY = "agentFileChangeReport"; export const AIR_NATIVE_SUBAGENT_SESSIONS_KEY = "nativeSubagentSessions"; export const AIR_ASYNC_TASKS_KEY = "asyncTasks"; export const AIR_RECOMMENDED_CONFIG_VALUE_KEY = "recommendedValue"; +export const AIR_SESSION_REWIND_KEY = "sessionRewind"; export const AIR_ASYNC_TASKS_BACKGROUNDED_KEY = "backgrounded"; export const AIR_AGENT_FILE_CHANGE_REPORT_REQUEST_KEY = "agentFileChangeReportRequest"; export const AIR_EXTENSION_VERSION = 1; diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index 7040057f2..8166c5a48 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -69,6 +69,7 @@ import { } from "./AgentFileChangeReport"; import {CodexSubagentSubscriptions} from "./subagents/CodexSubagentSubscriptions"; import {forkSession as runForkSession} from "./SessionFork"; +import {rewindSession as runRewindSession, type SessionRewindRequest} from "./SessionRewind"; import type {SessionMetadata, SessionMetadataWithThread} from "./SessionMetadata"; export type {SessionMetadata, SessionMetadataWithThread} from "./SessionMetadata"; @@ -567,6 +568,10 @@ export class CodexAcpClient { }); } + async rewindSession(request: SessionRewindRequest): Promise<{rewound: boolean}> { + return await runRewindSession(request, this.codexClient); + } + async loadSession(request: acp.LoadSessionRequest, onSubscribed?: () => void): Promise { const additionalDirectories = readAdditionalDirectories(request.cwd, request.additionalDirectories, request._meta); await this.refreshSkills(request.cwd, additionalDirectories); diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index 4ff0e1b4b..120b77c79 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -78,6 +78,8 @@ import { type LegacySetSessionModelRequest, type LegacySetSessionModelResponse, SESSION_STEERING_METHOD, + SESSION_REWIND_METHOD, + type SessionRewindRequest, type SessionSteeringResponse, type SessionSteerRequest, } from "./AcpExtensions"; @@ -134,6 +136,7 @@ import { AIR_ASYNC_TASKS_KEY, AIR_NATIVE_SUBAGENT_SESSIONS_KEY, AIR_RECOMMENDED_CONFIG_VALUE_KEY, + AIR_SESSION_REWIND_KEY, AIR_EXTENSION_CAPABILITIES_KEY, AIR_EXTENSION_VERSION, AIR_EXTENSION_VERSION_KEY, @@ -394,6 +397,7 @@ export class CodexAcpServer { AIR_NATIVE_SUBAGENT_SESSIONS_KEY, AIR_ASYNC_TASKS_KEY, AIR_RECOMMENDED_CONFIG_VALUE_KEY, + AIR_SESSION_REWIND_KEY, ], }, }, @@ -429,6 +433,10 @@ export class CodexAcpServer { ), }; } + case SESSION_REWIND_METHOD: + return await this.runWithProcessCheck( + () => this.codexAcpClient.rewindSession(methodRequest.params as SessionRewindRequest), + ); case GOAL_CONTROL_METHOD: case LEGACY_GOAL_CONTROL_METHOD: { const sessionState = this.sessions.get(methodRequest.params.sessionId); diff --git a/src/CodexAppServerClient.ts b/src/CodexAppServerClient.ts index daa7e8752..71f91c34e 100644 --- a/src/CodexAppServerClient.ts +++ b/src/CodexAppServerClient.ts @@ -60,6 +60,8 @@ import type { ThreadTurnsListResponse, ThreadResumeParams, ThreadResumeResponse, + ThreadRevertParams, + ThreadRevertResponse, ThreadSettings, ThreadStartParams, ThreadStartResponse, @@ -558,6 +560,10 @@ export class CodexAppServerClient { return await this.sendRequest({ method: "thread/fork", params: params }); } + async threadRevert(params: ThreadRevertParams): Promise { + return await this.sendRequest({method: "thread/revert", params}); + } + getThreadSettings(threadId: string): ThreadSettings | undefined { return this.threadSettings.get(threadId); } diff --git a/src/SessionRewind.ts b/src/SessionRewind.ts new file mode 100644 index 000000000..69f8d464d --- /dev/null +++ b/src/SessionRewind.ts @@ -0,0 +1,57 @@ +import {createHash} from "node:crypto"; +import {RequestError} from "@agentclientprotocol/sdk"; +import type {CodexAppServerClient} from "./CodexAppServerClient"; + +export const SESSION_REWIND_METHOD = "_session/rewind"; +export const SESSION_REWIND_CAPABILITY = "sessionRewind"; + +export type SessionHistoryPoint = { + messageId: string; + messageFingerprint: string; + messageOccurrence: number; +}; + +export type SessionRewindRequest = { + sessionId: string; + beforeMessage: SessionHistoryPoint; + resumeAtMessage?: SessionHistoryPoint; +}; + +export type SessionRewindResponse = {rewound: boolean}; + +export async function rewindSession( + request: SessionRewindRequest, + client: CodexAppServerClient, +): Promise { + const history = await client.threadReadWithHistory(request.sessionId); + const userTurns = history.thread.turns.flatMap(turn => turn.items + .filter(item => item.type === "userMessage") + .map(item => ({turn, item}))); + const candidates = messageIdCandidates(request.beforeMessage.messageId); + const exact = userTurns.find(({item}) => candidates.includes(item.id)); + const fingerprintMatches = userTurns.filter(({item}) => + fingerprint(userMessageText(item.content)) === request.beforeMessage.messageFingerprint, + ); + const turn = exact?.turn ?? fingerprintMatches[request.beforeMessage.messageOccurrence - 1]?.turn; + if (!turn) { + throw RequestError.invalidParams( + {messageId: request.beforeMessage.messageId}, + `Rewind message ${request.beforeMessage.messageId} was not found in session ${request.sessionId}`, + ); + } + await client.threadRevert({threadId: request.sessionId, beforeTurnId: turn.id}); + return {rewound: true}; +} + +function userMessageText(content: Array<{type: string; text?: string}>): string { + return content.filter(item => item.type === "text").map(item => item.text ?? "").join(""); +} + +function fingerprint(text: string): string { + return `sha256:${createHash("sha256").update(text, "utf8").digest("hex")}`; +} + +function messageIdCandidates(messageId: string): string[] { + const protocolMessageId = messageId.replace(/:segment:\d+$/, ""); + return protocolMessageId === messageId ? [messageId] : [messageId, protocolMessageId]; +} diff --git a/src/__tests__/CodexACPAgent/initialize.test.ts b/src/__tests__/CodexACPAgent/initialize.test.ts index e6bdb8bb3..76c736901 100644 --- a/src/__tests__/CodexACPAgent/initialize.test.ts +++ b/src/__tests__/CodexACPAgent/initialize.test.ts @@ -78,7 +78,7 @@ describe('CodexACPAgent - initialize', () => { jetbrains: { air: { version: 1, - capabilities: ["sessionFailure", "agentFileChangeReport", "nativeSubagentSessions", "asyncTasks", "recommendedValue"], + capabilities: ["sessionFailure", "agentFileChangeReport", "nativeSubagentSessions", "asyncTasks", "recommendedValue", "sessionRewind"], }, }, }, diff --git a/src/__tests__/SessionRewind.test.ts b/src/__tests__/SessionRewind.test.ts new file mode 100644 index 000000000..37f8f535f --- /dev/null +++ b/src/__tests__/SessionRewind.test.ts @@ -0,0 +1,74 @@ +import {describe, expect, it, vi} from "vitest"; +import type {CodexAppServerClient} from "../CodexAppServerClient"; +import {rewindSession} from "../SessionRewind"; + +describe("session rewind", () => { + it("reverts the same Codex thread before the selected user turn", async () => { + const client = { + threadReadWithHistory: vi.fn().mockResolvedValue({ + thread: { + turns: [ + {id: "turn-1", items: [{type: "userMessage", id: "user-1", content: [{type: "text", text: "one"}]}]}, + {id: "turn-2", items: [{type: "userMessage", id: "user-2", content: [{type: "text", text: "two"}]}]}, + ], + }, + }), + threadRevert: vi.fn().mockResolvedValue({}), + } as unknown as CodexAppServerClient; + + const result = await rewindSession({ + sessionId: "thread-1", + beforeMessage: { + messageId: "user-2", + messageFingerprint: "sha256:3fc4ccfe745870e2c0d99f71f30ff0656c8d1ed5d3f3b71b17a64d1c0d9a4f5f", + messageOccurrence: 1, + }, + }, client); + + expect(result).toEqual({rewound: true}); + expect(client.threadRevert).toHaveBeenCalledWith({threadId: "thread-1", beforeTurnId: "turn-2"}); + }); + + it("resolves a restored message through its fingerprint occurrence", async () => { + const client = { + threadReadWithHistory: vi.fn().mockResolvedValue({ + thread: { + turns: [ + {id: "turn-1", items: [{type: "userMessage", id: "new-1", content: [{type: "text", text: "repeat"}]}]}, + {id: "turn-2", items: [{type: "userMessage", id: "new-2", content: [{type: "text", text: "repeat"}]}]}, + ], + }, + }), + threadRevert: vi.fn().mockResolvedValue({}), + } as unknown as CodexAppServerClient; + + const result = await rewindSession({ + sessionId: "thread-1", + beforeMessage: { + messageId: "stale-id", + messageFingerprint: "sha256:25e2b6b106523880e27763084ffa6a0756335be0d7106022535365b9ad39b4b1", + messageOccurrence: 2, + }, + }, client); + + expect(result).toEqual({rewound: true}); + expect(client.threadRevert).toHaveBeenCalledWith({threadId: "thread-1", beforeTurnId: "turn-2"}); + }); + + it("does not revert when the selected message is absent", async () => { + const client = { + threadReadWithHistory: vi.fn().mockResolvedValue({thread: {turns: []}}), + threadRevert: vi.fn(), + } as unknown as CodexAppServerClient; + + await expect(rewindSession({ + sessionId: "thread-1", + beforeMessage: { + messageId: "missing", + messageFingerprint: `sha256:${"0".repeat(64)}`, + messageOccurrence: 1, + }, + }, client)).rejects.toThrow("Rewind message missing was not found"); + expect(client.threadRevert).not.toHaveBeenCalled(); + }); +}); diff --git a/src/index.ts b/src/index.ts index 197593003..db31da96d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,6 +17,7 @@ import { SESSION_STEERING_METHOD, } from "./AcpExtensions"; import {ASYNC_TASK_STOP_METHOD} from "./async-tasks/AsyncTaskExtension"; +import {SESSION_REWIND_METHOD} from "./SessionRewind"; const emptyExtensionParamsParser = z.preprocess( (params) => params ?? {}, @@ -50,6 +51,18 @@ const asyncTaskStopParamsParser = z.object({ asyncTaskId: z.string().trim().min(1), }).passthrough(); +const sessionHistoryPointParser = z.object({ + messageId: z.string().trim().min(1), + messageFingerprint: z.string().regex(/^sha256:[0-9a-f]{64}$/), + messageOccurrence: z.number().int().positive(), +}); + +const sessionRewindParamsParser = z.object({ + sessionId: z.string().trim().min(1), + beforeMessage: sessionHistoryPointParser, + resumeAtMessage: sessionHistoryPointParser.optional(), +}).passthrough(); + if (process.argv.includes("--version")) { console.log(`${packageJson.name} ${packageJson.version}`); process.exit(0); @@ -168,6 +181,7 @@ function startAcpServer() { .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(ASYNC_TASK_STOP_METHOD, asyncTaskStopParamsParser, (ctx) => getAgent().extMethod(ASYNC_TASK_STOP_METHOD, ctx.params)) + .onRequest(SESSION_REWIND_METHOD, sessionRewindParamsParser, (ctx) => getAgent().extMethod(SESSION_REWIND_METHOD, ctx.params)) .onRequest(GOAL_CONTROL_METHOD, goalControlParamsParser, (ctx) => getAgent().extMethod(GOAL_CONTROL_METHOD, ctx.params)) .connect(acpJsonStream); } From 28d0be7c0c0703c36a57e7391cb529eda4c59018 Mon Sep 17 00:00:00 2001 From: Nikita Ashikhmin Date: Tue, 15 Sep 2026 13:23:45 +0400 Subject: [PATCH 2/8] fix: keep session rewind histories aligned --- docs/session-rewind-extension.md | 4 +- src/CodexAcpServer.ts | 31 +-------- src/CodexAppServerClient.ts | 6 ++ src/SessionRewind.ts | 33 ++++++---- src/UserInputContent.ts | 40 ++++++++++++ src/__tests__/SessionRewind.test.ts | 97 +++++++++++++++++++++++++++++ 6 files changed, 169 insertions(+), 42 deletions(-) create mode 100644 src/UserInputContent.ts diff --git a/docs/session-rewind-extension.md b/docs/session-rewind-extension.md index b26b2c619..54df9e92b 100644 --- a/docs/session-rewind-extension.md +++ b/docs/session-rewind-extension.md @@ -49,8 +49,8 @@ The adapter returns `{ "rewound": true }` only after Codex accepts the rewind. A ## Codex mapping -The adapter reads the existing Codex thread history and resolves `beforeMessage` to its containing turn. It then calls `thread/revert` with that turn as the exclusive boundary. +The adapter reads the existing Codex thread history and resolves `beforeMessage` to its containing turn. Rewind is rejected when the selected message is a steer inside an existing turn because Codex cannot remove only that suffix. For paginated history, the adapter calls `thread/revert` with the containing turn as the exclusive boundary; legacy history uses the equivalent turn-count rollback operation. -The Codex thread ID remains the ACP session ID. The adapter does not call `thread/fork`, create a thread, or add a session-list entry. `resumeAtMessage` is not needed for this mapping because Codex reverts at a turn boundary. +The Codex thread ID remains the ACP session ID. The adapter does not call `thread/fork`, create a thread, or add a session-list entry. `resumeAtMessage` is not needed after the turn-boundary validation. After a successful response, the client can remove the same transcript suffix and place the selected user text in its editor. diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index 120b77c79..39c6c8b2b 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -60,6 +60,7 @@ import type {QuotaMeta} from "./QuotaMeta"; import {logger} from "./Logger"; import {sanitizeMcpServerName} from "./McpServerName"; import {createResponseItemHistoryFallbackUpdates} from "./ResponseItemHistoryFallback"; +import {userInputToContentBlocks} from "./UserInputContent"; import { AUTH_STATUS_META_KEY, AUTH_STATUS_UPDATE_METHOD, @@ -2294,7 +2295,7 @@ export class CodexAcpServer { const updates: UpdateSessionEvent[] = []; const messageId = item.id; for (const input of item.content) { - const blocks = this.userInputToContentBlocks(input); + const blocks = userInputToContentBlocks(input); for (const block of blocks) { updates.push(createUserMessageChunk(block, messageId)); } @@ -2357,34 +2358,6 @@ export class CodexAcpServer { ); } - private userInputToContentBlocks(input: UserInput): acp.ContentBlock[] { - switch (input.type) { - case "text": - return input.text.length > 0 ? [{ type: "text", text: input.text }] : []; - case "image": - return [{ type: "text", text: this.formatUriAsLink("image", input.url) }]; - case "localImage": { - const uri = input.path.startsWith("file://") ? input.path : `file://${input.path}`; - return [{ type: "text", text: this.formatUriAsLink(null, uri) }]; - } - case "skill": - return [{ type: "text", text: `skill:${input.name} (${input.path})` }]; - } - return []; - } - - private formatUriAsLink(name: string | null, uri: string): string { - if (name && name.length > 0) { - return `[@${name}](${uri})`; - } - if (uri.startsWith("file://")) { - const path = uri.replace("file://", ""); - const fileName = path.split("/").pop() ?? path; - return `[@${fileName}](${uri})`; - } - return uri; - } - getSessionState(sessionId: string): SessionState { const sessionState = this.sessions.get(sessionId); if (!sessionState) { diff --git a/src/CodexAppServerClient.ts b/src/CodexAppServerClient.ts index 71f91c34e..39364c96d 100644 --- a/src/CodexAppServerClient.ts +++ b/src/CodexAppServerClient.ts @@ -62,6 +62,8 @@ import type { ThreadResumeResponse, ThreadRevertParams, ThreadRevertResponse, + ThreadRollbackParams, + ThreadRollbackResponse, ThreadSettings, ThreadStartParams, ThreadStartResponse, @@ -564,6 +566,10 @@ export class CodexAppServerClient { return await this.sendRequest({method: "thread/revert", params}); } + async threadRollback(params: ThreadRollbackParams): Promise { + return await this.sendRequest({method: "thread/rollback", params}); + } + getThreadSettings(threadId: string): ThreadSettings | undefined { return this.threadSettings.get(threadId); } diff --git a/src/SessionRewind.ts b/src/SessionRewind.ts index 69f8d464d..2ccbed23b 100644 --- a/src/SessionRewind.ts +++ b/src/SessionRewind.ts @@ -1,6 +1,7 @@ import {createHash} from "node:crypto"; import {RequestError} from "@agentclientprotocol/sdk"; import type {CodexAppServerClient} from "./CodexAppServerClient"; +import {userInputVisibleText} from "./UserInputContent"; export const SESSION_REWIND_METHOD = "_session/rewind"; export const SESSION_REWIND_CAPABILITY = "sessionRewind"; @@ -24,29 +25,39 @@ export async function rewindSession( client: CodexAppServerClient, ): Promise { const history = await client.threadReadWithHistory(request.sessionId); - const userTurns = history.thread.turns.flatMap(turn => turn.items - .filter(item => item.type === "userMessage") - .map(item => ({turn, item}))); + const userTurns = history.thread.turns.flatMap((turn, turnIndex) => turn.items + .flatMap((item, itemIndex) => item.type === "userMessage" + ? [{turn, turnIndex, item, itemIndex}] + : [])); const candidates = messageIdCandidates(request.beforeMessage.messageId); const exact = userTurns.find(({item}) => candidates.includes(item.id)); const fingerprintMatches = userTurns.filter(({item}) => - fingerprint(userMessageText(item.content)) === request.beforeMessage.messageFingerprint, + fingerprint(userInputVisibleText(item.content)) === request.beforeMessage.messageFingerprint, ); - const turn = exact?.turn ?? fingerprintMatches[request.beforeMessage.messageOccurrence - 1]?.turn; - if (!turn) { + const match = exact ?? fingerprintMatches[request.beforeMessage.messageOccurrence - 1]; + if (!match) { throw RequestError.invalidParams( {messageId: request.beforeMessage.messageId}, `Rewind message ${request.beforeMessage.messageId} was not found in session ${request.sessionId}`, ); } - await client.threadRevert({threadId: request.sessionId, beforeTurnId: turn.id}); + if (match.itemIndex !== 0) { + throw RequestError.invalidParams( + {messageId: request.beforeMessage.messageId}, + `Rewind message ${request.beforeMessage.messageId} does not start a turn`, + ); + } + if (history.thread.historyMode === "legacy") { + await client.threadRollback({ + threadId: request.sessionId, + numTurns: history.thread.turns.length - match.turnIndex, + }); + } else { + await client.threadRevert({threadId: request.sessionId, beforeTurnId: match.turn.id}); + } return {rewound: true}; } -function userMessageText(content: Array<{type: string; text?: string}>): string { - return content.filter(item => item.type === "text").map(item => item.text ?? "").join(""); -} - function fingerprint(text: string): string { return `sha256:${createHash("sha256").update(text, "utf8").digest("hex")}`; } diff --git a/src/UserInputContent.ts b/src/UserInputContent.ts new file mode 100644 index 000000000..e7502d655 --- /dev/null +++ b/src/UserInputContent.ts @@ -0,0 +1,40 @@ +import * as acp from "@agentclientprotocol/sdk"; +import type {UserInput} from "./app-server/v2"; + +export function userInputToContentBlocks(input: UserInput): acp.ContentBlock[] { + switch (input.type) { + case "text": + return input.text.length > 0 ? [{type: "text", text: input.text}] : []; + case "image": + return [{type: "text", text: formatUriAsLink("image", input.url)}]; + case "localImage": { + const uri = input.path.startsWith("file://") ? input.path : `file://${input.path}`; + return [{type: "text", text: formatUriAsLink(null, uri)}]; + } + case "skill": + return [{type: "text", text: `skill:${input.name} (${input.path})`}]; + case "audio": + case "localAudio": + case "mention": + return []; + } +} + +export function userInputVisibleText(content: UserInput[]): string { + return content.flatMap(userInputToContentBlocks) + .filter((block): block is Extract => block.type === "text") + .map(block => block.text) + .join(""); +} + +function formatUriAsLink(name: string | null, uri: string): string { + if (name && name.length > 0) { + return `[@${name}](${uri})`; + } + if (uri.startsWith("file://")) { + const path = uri.replace("file://", ""); + const fileName = path.split("/").pop() ?? path; + return `[@${fileName}](${uri})`; + } + return uri; +} diff --git a/src/__tests__/SessionRewind.test.ts b/src/__tests__/SessionRewind.test.ts index 37f8f535f..fd91e891a 100644 --- a/src/__tests__/SessionRewind.test.ts +++ b/src/__tests__/SessionRewind.test.ts @@ -55,6 +55,103 @@ describe("session rewind", () => { expect(client.threadRevert).toHaveBeenCalledWith({threadId: "thread-1", beforeTurnId: "turn-2"}); }); + it("uses the visible replay text when fingerprinting multimodal and skill inputs", async () => { + const client = { + threadReadWithHistory: vi.fn().mockResolvedValue({ + thread: { + historyMode: "paginated", + turns: [{ + id: "turn-1", + items: [{ + type: "userMessage", + id: "new-id", + content: [ + {type: "text", text: "look"}, + {type: "image", url: "https://example.com/image.png"}, + {type: "skill", name: "review", path: "/tmp/SKILL.md"}, + ], + }], + }], + }, + }), + threadRevert: vi.fn().mockResolvedValue({}), + } as unknown as CodexAppServerClient; + + await rewindSession({ + sessionId: "thread-1", + beforeMessage: { + messageId: "stale-id", + messageFingerprint: "sha256:d0425f232dd6a5d6a18eee0fb305ff976368b93fb5ad3da67a4b41d919f7e2de", + messageOccurrence: 1, + }, + }, client); + + expect(client.threadRevert).toHaveBeenCalledWith({threadId: "thread-1", beforeTurnId: "turn-1"}); + }); + + it("uses turn-count rollback for legacy thread history", async () => { + const client = { + threadReadWithHistory: vi.fn().mockResolvedValue({ + thread: { + historyMode: "legacy", + turns: [ + {id: "turn-1", items: [{type: "userMessage", id: "user-1", content: [{type: "text", text: "one"}]}]}, + {id: "turn-2", items: [{type: "userMessage", id: "user-2", content: [{type: "text", text: "two"}]}]}, + {id: "turn-3", items: [{type: "userMessage", id: "user-3", content: [{type: "text", text: "three"}]}]}, + ], + }, + }), + threadRollback: vi.fn().mockResolvedValue({}), + threadRevert: vi.fn(), + } as unknown as CodexAppServerClient; + + await rewindSession({ + sessionId: "thread-1", + beforeMessage: { + messageId: "user-2", + messageFingerprint: "sha256:3fc4ccfe745870e2c0d99f71f30ff0656c8d1ed5d3f3b71b17a64d1c0d9a4f5f", + messageOccurrence: 1, + }, + }, client); + + expect(client.threadRollback).toHaveBeenCalledWith({threadId: "thread-1", numTurns: 2}); + expect(client.threadRevert).not.toHaveBeenCalled(); + }); + + it("rejects rewinding a steer inside an existing turn", async () => { + const client = { + threadReadWithHistory: vi.fn().mockResolvedValue({ + thread: { + historyMode: "paginated", + turns: [{ + id: "turn-1", + items: [ + {type: "userMessage", id: "user-1", content: [{type: "text", text: "first"}]}, + {type: "agentMessage", id: "assistant-1", text: "working"}, + {type: "userMessage", id: "steer-1", content: [{type: "text", text: "steer"}]}, + ], + }], + }, + }), + threadRevert: vi.fn(), + } as unknown as CodexAppServerClient; + + await expect(rewindSession({ + sessionId: "thread-1", + beforeMessage: { + messageId: "steer-1", + messageFingerprint: "sha256:57fce44d7c6df51ad8525da1580a246e9d1142d79d1d1f176b1d29643d61ed44", + messageOccurrence: 1, + }, + resumeAtMessage: { + messageId: "assistant-1", + messageFingerprint: `sha256:${"0".repeat(64)}`, + messageOccurrence: 1, + }, + }, client)).rejects.toThrow("does not start a turn"); + expect(client.threadRevert).not.toHaveBeenCalled(); + }); + it("does not revert when the selected message is absent", async () => { const client = { threadReadWithHistory: vi.fn().mockResolvedValue({thread: {turns: []}}), From 10ef83cce6a72a2ee0df3dcad3d88cf5cfc29638 Mon Sep 17 00:00:00 2001 From: Nikita Ashikhmin Date: Tue, 15 Sep 2026 13:33:34 +0400 Subject: [PATCH 3/8] fix: serialize session rewind lifecycle --- src/CodexAcpClient.ts | 4 +- src/CodexAcpServer.ts | 3 ++ src/SessionRewind.ts | 4 +- src/__tests__/CodexACPAgent/providers.test.ts | 49 +++++++++++++++++ src/__tests__/SessionRewind.test.ts | 52 +++++++++++++++++++ 5 files changed, 110 insertions(+), 2 deletions(-) diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index 8166c5a48..0c3c10c4e 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -569,7 +569,9 @@ export class CodexAcpClient { } async rewindSession(request: SessionRewindRequest): Promise<{rewound: boolean}> { - return await runRewindSession(request, this.codexClient); + const response = await runRewindSession(request, this.codexClient); + await this.waitForSessionNotifications(request.sessionId); + return response; } async loadSession(request: acp.LoadSessionRequest, onSubscribed?: () => void): Promise { diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index 39c6c8b2b..9d9e6586b 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -435,6 +435,9 @@ export class CodexAcpServer { }; } case SESSION_REWIND_METHOD: + if (this.providerUpdate !== null) { + await this.providerUpdate; + } return await this.runWithProcessCheck( () => this.codexAcpClient.rewindSession(methodRequest.params as SessionRewindRequest), ); diff --git a/src/SessionRewind.ts b/src/SessionRewind.ts index 2ccbed23b..84eba1079 100644 --- a/src/SessionRewind.ts +++ b/src/SessionRewind.ts @@ -30,7 +30,9 @@ export async function rewindSession( ? [{turn, turnIndex, item, itemIndex}] : [])); const candidates = messageIdCandidates(request.beforeMessage.messageId); - const exact = userTurns.find(({item}) => candidates.includes(item.id)); + const exact = candidates + .map(candidate => userTurns.find(({item}) => item.id === candidate)) + .find(match => match !== undefined); const fingerprintMatches = userTurns.filter(({item}) => fingerprint(userInputVisibleText(item.content)) === request.beforeMessage.messageFingerprint, ); diff --git a/src/__tests__/CodexACPAgent/providers.test.ts b/src/__tests__/CodexACPAgent/providers.test.ts index 5a7240395..db05eb521 100644 --- a/src/__tests__/CodexACPAgent/providers.test.ts +++ b/src/__tests__/CodexACPAgent/providers.test.ts @@ -2,6 +2,15 @@ import {describe, expect, it, vi} from "vitest"; import * as acp from "@agentclientprotocol/sdk"; import {createCodexMockTestFixture, createTestSessionState} from "../acp-test-utils"; import {CodexAcpClient, CUSTOM_GATEWAY_PROVIDER_ID, OPENAI_PROVIDER_ID} from "../../CodexAcpClient"; +import {SESSION_REWIND_METHOD} from "../../SessionRewind"; + +function deferred(): {promise: Promise, resolve: (value: T) => void} { + let resolve!: (value: T) => void; + const promise = new Promise(resolvePromise => { + resolve = resolvePromise; + }); + return {promise, resolve}; +} async function expectInvalidParams(fn: () => unknown): Promise { const caught = await Promise.resolve().then(fn).catch((err: unknown) => err); @@ -304,6 +313,46 @@ describe("Configurable LLM providers (providers/*)", () => { }); }); + it("waits for an in-flight provider update before rewinding", async () => { + const replacement = createCodexMockTestFixture().getCodexAcpClient(); + const resumeStarted = deferred(); + const resume = deferred(); + vi.spyOn(replacement, "initialize").mockResolvedValue(); + vi.spyOn(replacement, "resumeSession").mockImplementation(async () => { + resumeStarted.resolve(); + return await resume.promise; + }); + const replacementRewind = vi.spyOn(replacement, "rewindSession").mockResolvedValue({rewound: true}); + const fixture = createCodexMockTestFixture(vi.fn().mockResolvedValue(replacement)); + const agent = fixture.getCodexAcpAgent(); + await agent.initialize({protocolVersion: acp.PROTOCOL_VERSION}); + const sessions = (agent as unknown as {sessions: Map>}).sessions; + sessions.set("thread-1", createTestSessionState({sessionId: "thread-1", cwd: "/workspace"})); + + const providerUpdate = agent.setProvider({ + providerId: OPENAI_PROVIDER_ID, + apiType: "openai", + baseUrl: "https://gateway.example/v1", + }); + await resumeStarted.promise; + const rewind = agent.extMethod(SESSION_REWIND_METHOD, { + sessionId: "thread-1", + beforeMessage: { + messageId: "user-1", + messageFingerprint: `sha256:${"0".repeat(64)}`, + messageOccurrence: 1, + }, + }); + + await Promise.resolve(); + expect(replacementRewind).not.toHaveBeenCalled(); + + resume.resolve({} as never); + await providerUpdate; + await expect(rewind).resolves.toEqual({rewound: true}); + expect(replacementRewind).toHaveBeenCalledOnce(); + }); + it("shares state with the legacy gateway auth method", async () => { const fixture = createCodexMockTestFixture(); const codexAcpClient = fixture.getCodexAcpClient(); diff --git a/src/__tests__/SessionRewind.test.ts b/src/__tests__/SessionRewind.test.ts index fd91e891a..389c75d17 100644 --- a/src/__tests__/SessionRewind.test.ts +++ b/src/__tests__/SessionRewind.test.ts @@ -1,5 +1,6 @@ import {describe, expect, it, vi} from "vitest"; import type {CodexAppServerClient} from "../CodexAppServerClient"; +import {CodexAcpClient} from "../CodexAcpClient"; import {rewindSession} from "../SessionRewind"; describe("session rewind", () => { @@ -55,6 +56,31 @@ describe("session rewind", () => { expect(client.threadRevert).toHaveBeenCalledWith({threadId: "thread-1", beforeTurnId: "turn-2"}); }); + it("prefers the exact segmented message id over its protocol id fallback", async () => { + const client = { + threadReadWithHistory: vi.fn().mockResolvedValue({ + thread: { + turns: [ + {id: "turn-1", items: [{type: "userMessage", id: "user-1", content: [{type: "text", text: "fallback"}]}]}, + {id: "turn-2", items: [{type: "userMessage", id: "user-1:segment:0", content: [{type: "text", text: "exact"}]}]}, + ], + }, + }), + threadRevert: vi.fn().mockResolvedValue({}), + } as unknown as CodexAppServerClient; + + await rewindSession({ + sessionId: "thread-1", + beforeMessage: { + messageId: "user-1:segment:0", + messageFingerprint: `sha256:${"0".repeat(64)}`, + messageOccurrence: 1, + }, + }, client); + + expect(client.threadRevert).toHaveBeenCalledWith({threadId: "thread-1", beforeTurnId: "turn-2"}); + }); + it("uses the visible replay text when fingerprinting multimodal and skill inputs", async () => { const client = { threadReadWithHistory: vi.fn().mockResolvedValue({ @@ -168,4 +194,30 @@ describe("session rewind", () => { }, client)).rejects.toThrow("Rewind message missing was not found"); expect(client.threadRevert).not.toHaveBeenCalled(); }); + + it("drains queued session notifications before acknowledging rewind", async () => { + const appServerClient = { + threadReadWithHistory: vi.fn().mockResolvedValue({ + thread: { + historyMode: "paginated", + turns: [{id: "turn-1", items: [{type: "userMessage", id: "user-1", content: [{type: "text", text: "one"}]}]}], + }, + }), + threadRevert: vi.fn().mockResolvedValue({}), + } as unknown as CodexAppServerClient; + const client = new CodexAcpClient(appServerClient); + const waitForNotifications = vi.spyOn(client, "waitForSessionNotifications").mockResolvedValue(); + + await client.rewindSession({ + sessionId: "thread-1", + beforeMessage: { + messageId: "user-1", + messageFingerprint: `sha256:${"0".repeat(64)}`, + messageOccurrence: 1, + }, + }); + + expect(waitForNotifications).toHaveBeenCalledWith("thread-1"); + expect(appServerClient.threadRevert).toHaveBeenCalledBefore(waitForNotifications); + }); }); From c2c9026b315a83dc3cf8b659afbf2108ddedf73e Mon Sep 17 00:00:00 2001 From: Nikita Ashikhmin Date: Tue, 15 Sep 2026 13:41:42 +0400 Subject: [PATCH 4/8] fix: validate session rewind targets --- src/CodexAcpServer.ts | 6 ++++ src/SessionRewind.ts | 5 ++- src/__tests__/CodexACPAgent/providers.test.ts | 15 +++++++++ src/__tests__/SessionRewind.test.ts | 31 +++++++++++++++++++ 4 files changed, 54 insertions(+), 3 deletions(-) diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index 9d9e6586b..3172f4de7 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -438,6 +438,12 @@ export class CodexAcpServer { if (this.providerUpdate !== null) { await this.providerUpdate; } + if (!this.sessions.has(methodRequest.params.sessionId)) { + throw RequestError.invalidParams( + undefined, + `Unknown session: ${methodRequest.params.sessionId}`, + ); + } return await this.runWithProcessCheck( () => this.codexAcpClient.rewindSession(methodRequest.params as SessionRewindRequest), ); diff --git a/src/SessionRewind.ts b/src/SessionRewind.ts index 84eba1079..a905d2a8e 100644 --- a/src/SessionRewind.ts +++ b/src/SessionRewind.ts @@ -33,10 +33,9 @@ export async function rewindSession( const exact = candidates .map(candidate => userTurns.find(({item}) => item.id === candidate)) .find(match => match !== undefined); - const fingerprintMatches = userTurns.filter(({item}) => + const match = exact ?? userTurns.filter(({item}) => fingerprint(userInputVisibleText(item.content)) === request.beforeMessage.messageFingerprint, - ); - const match = exact ?? fingerprintMatches[request.beforeMessage.messageOccurrence - 1]; + )[request.beforeMessage.messageOccurrence - 1]; if (!match) { throw RequestError.invalidParams( {messageId: request.beforeMessage.messageId}, diff --git a/src/__tests__/CodexACPAgent/providers.test.ts b/src/__tests__/CodexACPAgent/providers.test.ts index db05eb521..31428894e 100644 --- a/src/__tests__/CodexACPAgent/providers.test.ts +++ b/src/__tests__/CodexACPAgent/providers.test.ts @@ -353,6 +353,21 @@ describe("Configurable LLM providers (providers/*)", () => { expect(replacementRewind).toHaveBeenCalledOnce(); }); + it("rejects rewind for a thread that is not a loaded ACP session", async () => { + const fixture = createCodexMockTestFixture(); + const rewind = vi.spyOn(fixture.getCodexAcpClient(), "rewindSession"); + + await expect(fixture.getCodexAcpAgent().extMethod(SESSION_REWIND_METHOD, { + sessionId: "persisted-but-not-loaded", + beforeMessage: { + messageId: "user-1", + messageFingerprint: `sha256:${"0".repeat(64)}`, + messageOccurrence: 1, + }, + })).rejects.toThrow("Unknown session: persisted-but-not-loaded"); + expect(rewind).not.toHaveBeenCalled(); + }); + it("shares state with the legacy gateway auth method", async () => { const fixture = createCodexMockTestFixture(); const codexAcpClient = fixture.getCodexAcpClient(); diff --git a/src/__tests__/SessionRewind.test.ts b/src/__tests__/SessionRewind.test.ts index 389c75d17..bbc43e011 100644 --- a/src/__tests__/SessionRewind.test.ts +++ b/src/__tests__/SessionRewind.test.ts @@ -81,6 +81,37 @@ describe("session rewind", () => { expect(client.threadRevert).toHaveBeenCalledWith({threadId: "thread-1", beforeTurnId: "turn-2"}); }); + it("does not fingerprint history when an exact message id is found", async () => { + const unreadableItem = Object.defineProperty({type: "userMessage", id: "other"}, "content", { + enumerable: true, + get: () => { + throw new Error("fingerprint fallback should not run"); + }, + }); + const client = { + threadReadWithHistory: vi.fn().mockResolvedValue({ + thread: { + turns: [ + {id: "turn-1", items: [unreadableItem]}, + {id: "turn-2", items: [{type: "userMessage", id: "exact", content: [{type: "text", text: "selected"}]}]}, + ], + }, + }), + threadRevert: vi.fn().mockResolvedValue({}), + } as unknown as CodexAppServerClient; + + await rewindSession({ + sessionId: "thread-1", + beforeMessage: { + messageId: "exact", + messageFingerprint: `sha256:${"0".repeat(64)}`, + messageOccurrence: 1, + }, + }, client); + + expect(client.threadRevert).toHaveBeenCalledWith({threadId: "thread-1", beforeTurnId: "turn-2"}); + }); + it("uses the visible replay text when fingerprinting multimodal and skill inputs", async () => { const client = { threadReadWithHistory: vi.fn().mockResolvedValue({ From d73d6f0a341139aac8c69553e337a8aab09e43e5 Mon Sep 17 00:00:00 2001 From: Wolf-L <50284298+Wolf-L@users.noreply.github.com> Date: Fri, 9 Oct 2026 03:08:06 +0800 Subject: [PATCH 5/8] feat: harden native session rewind and add scoped session controls --- README.md | 3 + docs/native-session-extensions.md | 39 ++ docs/native-session-extensions.zh-CN.md | 31 ++ docs/session-rewind-extension.md | 17 +- src/AcpExtensions.ts | 3 +- src/CodexAcpClient.ts | 25 +- src/CodexAcpServer.ts | 437 ++++++++++++++++-- src/CodexAppServerClient.ts | 53 ++- src/CodexJsonRpcConnection.ts | 23 + src/DesktopAttachmentHistory.ts | 2 +- src/SessionArchive.ts | 7 + src/SessionDiscovery.ts | 73 +++ src/SessionFileRevert.ts | 300 ++++++++++++ src/SessionQueue.ts | 191 ++++++++ src/SessionRewind.ts | 118 +++-- src/SessionRuntime.ts | 119 +++++ src/UserInputContent.ts | 44 +- .../CodexACPAgent/CodexAcpClient.test.ts | 7 +- .../CodexACPAgent/initialize.test.ts | 16 +- .../session-rewind-lifecycle.test.ts | 217 +++++++++ src/__tests__/SessionDiscovery.test.ts | 31 ++ src/__tests__/SessionFileRevert.test.ts | 175 +++++++ src/__tests__/SessionQueue.test.ts | 196 ++++++++ src/__tests__/SessionRewind.test.ts | 364 +++++---------- src/__tests__/SessionRuntime.test.ts | 51 ++ src/__tests__/scenarios/baseline.ts | 14 + .../air/agent-message-and-reasoning.jsonl | 2 +- .../data/air/background-terminal.jsonl | 2 +- .../scenarios/data/air/collab-agent.jsonl | 2 +- .../scenarios/data/air/collab-controls.jsonl | 2 +- .../scenarios/data/air/command-approval.jsonl | 2 +- .../scenarios/data/air/command-failed.jsonl | 2 +- .../data/air/command-output-stdin.jsonl | 2 +- .../air/command-without-streamed-output.jsonl | 2 +- .../data/air/context-compaction.jsonl | 2 +- .../scenarios/data/air/dynamic-tool.jsonl | 2 +- .../data/air/file-change-approval.jsonl | 2 +- .../scenarios/data/air/file-changes.jsonl | 2 +- .../data/air/fuzzy-file-search.jsonl | 2 +- .../scenarios/data/air/goal-update.jsonl | 2 +- .../scenarios/data/air/guardian-review.jsonl | 2 +- .../scenarios/data/air/history-replay.jsonl | 2 +- .../scenarios/data/air/image-generation.jsonl | 2 +- .../scenarios/data/air/image-view.jsonl | 2 +- .../data/air/late-subagent-update.jsonl | 2 +- .../scenarios/data/air/mcp-elicitation.jsonl | 2 +- .../data/air/mcp-startup-failure.jsonl | 2 +- .../data/air/mcp-tool-approval.jsonl | 2 +- .../scenarios/data/air/mcp-tool.jsonl | 2 +- .../data/air/mcp-url-elicitation.jsonl | 2 +- .../data/air/native-subagent-session.jsonl | 2 +- .../data/air/nested-subagent-session.jsonl | 2 +- .../air/network-and-sandbox-approval.jsonl | 2 +- .../data/air/plan-deltas-and-turn-plan.jsonl | 2 +- .../data/air/plan-review-permission.jsonl | 2 +- .../scenarios/data/air/plan-stream.jsonl | 2 +- .../scenarios/data/air/read-search-list.jsonl | 2 +- .../data/air/subagent-activity.jsonl | 2 +- .../scenarios/data/air/web-search.jsonl | 2 +- src/__tests__/scenarios/scenario-harness.ts | 6 +- src/index.ts | 27 ++ .../reporters/FuzzySearchReporter.ts | 2 +- 62 files changed, 2272 insertions(+), 385 deletions(-) create mode 100644 docs/native-session-extensions.md create mode 100644 docs/native-session-extensions.zh-CN.md create mode 100644 src/SessionArchive.ts create mode 100644 src/SessionDiscovery.ts create mode 100644 src/SessionFileRevert.ts create mode 100644 src/SessionQueue.ts create mode 100644 src/SessionRuntime.ts create mode 100644 src/__tests__/CodexACPAgent/session-rewind-lifecycle.test.ts create mode 100644 src/__tests__/SessionDiscovery.test.ts create mode 100644 src/__tests__/SessionFileRevert.test.ts create mode 100644 src/__tests__/SessionQueue.test.ts create mode 100644 src/__tests__/SessionRuntime.test.ts diff --git a/README.md b/README.md index 0c4ba6678..c1398636e 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ Use [OpenAI Codex](https://github.com/openai/codex) from [Agent Client Protocol] - Typed warnings and errors through the opt-in [AIR session failure extension](docs/air-extensions.md#session-failure). - All AIR extensions, capabilities, and `_meta` keys: [AIR extensions](docs/air-extensions.md). - A per-turn [agent file-change report](docs/air-extensions.md#agent-file-change-report) after capability negotiation. +- In-place message editing through the AIR [session rewind extension](docs/session-rewind-extension.md), without a provider fork. - Client-provided MCP servers over command-based stdio config and HTTP transport. - Slash commands: `/status`, `/mcp` (with `/mcp reconnect`), `/skills`, `/goal`, `/review`, `/review-branch`, `/review-commit`, `/compact`, and `/logout`, as well as configured skills. @@ -107,3 +108,5 @@ See [AIR extensions](docs/air-extensions.md#async-tasks) for the capability, lif ## License By contributing, you agree that your contributions will be licensed under the Apache 2.0 License. + +Local native-session test extensions: [English](docs/native-session-extensions.md) | [简体中文](docs/native-session-extensions.zh-CN.md). diff --git a/docs/native-session-extensions.md b/docs/native-session-extensions.md new file mode 100644 index 000000000..f2a894189 --- /dev/null +++ b/docs/native-session-extensions.md @@ -0,0 +1,39 @@ +# Local native session extensions + +This local test build integrates PR #508 on main `51aa211` (adapter 2.1.1, locked Codex 0.160.1, ACP SDK 1.5.0). It preserves current-main hooks, attachments, tool-call reports and compaction handling. No Codeg changes, global installation, push or new PR are included. + +## Negotiated operations + +Read the `initialize` metadata before invoking any extension. These are named methods with strict schemas, not a generic native RPC tunnel. + +| Capability | Method | Contract | +|---|---|---| +| AIR `sessionRewind` | `_session/rewind` | Same ID, exclusive `beforeMessage`, optional `resumeAtMessage`; `{rewound:true}` only after persisted-prefix verification. | +| `_meta.steering.idleBehavior` | `_session/steering` | Opt in with `_meta.steering.idleBehavior:"promptRequired"`; idle returns `{outcome:"promptRequired"}` without starting a model request. Non-opted clients retain the existing idle new-turn behavior. | +| `_meta.runtime` | `_session/runtime/read` | `resource`: context, usage, mcp, commands, plugins. Unknown measurements stay null. Commands are native skills; this is not a list of every Desktop UI command. | +| `_meta.runtime` | `_session/runtime/control` | `action`: reloadSkills, reconnectMcp, reloadPlugins. Skill reload republishes available commands. MCP/plugins are provider-wide idle operations; their acknowledgement is not runtime readiness. | +| `_meta.archive` | `_session/archive`, `_session/unarchive` | `{sessionId}`; reversible native archive, same ID. Existing standard session/delete keeps its prior archive-backed behavior. No permanent delete. Archive closes local routing; unarchive requires load to reopen. | +| `_meta.discovery` | `_session/search` | Bounded searchTerm/cursor/limit/archived. Searches user sources appServer/cli/vscode. Current native search can miss text retained only through ancestor rollout lineage after rewind. | +| `_meta.discovery` | `_session/attachments` | list/add/remove native metadata only. Does not create/delete a file, worktree, PR or prompt attachment. Loaded-session ownership required. | +| `_meta.queue` | `_session/queue` | Only list is advertised after actual-native version and positive nonmutating method probe. Writes/start return unsupported before native mutation. Pending native queues prevent ACP load/resume to avoid unowned auto-dispatch. | +| `_meta.fileRevert` | `_session/files/revert` | `{sessionId,toolCallId,dryRun,previewToken?}`; reverse one completed native fileChange tool's recorded text patches in Git. Preview token required for apply. History stays unchanged. | + +## File reversal + +Preview and commit re-read native history and fingerprint affected files. A stale preview, conflict, unknown change kind, binary/unsupported patch or unsafe path fails closed for the entire batch. Git uses reverse check/apply without --index/--cached/--3way, preserving the user's index and unrelated bytes. Reverted text follows Git attributes/autocrlf and is not promised to be byte-identical to its original encoding/newlines. Shell changes, unrecorded subagents and whole-workspace checkpoints are outside coverage. + +The server reserves the provider while validating loaded native threads and background terminals. External editors and unrelated processes are not controlled by this fence; Git conflict checks and fresh fingerprints narrow but do not eliminate an OS-level race. + +## Timeouts and recovery + +Runtime mutations have a 30-second deadline. Timeout invalidates local sessions and terminates the owned provider; reconnect the ACP adapter and explicitly load a session. No mutation is replayed. Windows uses taskkill /T for the owned launcher tree; the current POSIX kill path only guarantees termination of the launcher, so equivalent process-tree termination is not yet proven. Read timeouts return unavailable. A history mutation with an uncertain outcome blocks further sends until successful load/reconciliation. + +## Desktop parity boundaries + +Supported and locally exercised: same-ID first/latest/historical/repeated rewind, cancel-and-settle, cold load, resend context truncation, runtime status/control, archive/unarchive, attachment metadata persistence, safe idle steering, native search of current rollout, queue inspection and per-tool text patch reversal. + +Not claimed: full native queue automatic-turn adoption, realtime audio/voice, timeline API, complete Desktop project/worktree/UI management, whole-session file checkpoint restore, binary reverse patches, all child process/background writer isolation, or complete search over retained ancestor rollouts. A native schema method does not establish an implemented ACP handler. + +## Validation + +Full suite: 82 test files passed, 8 skipped; 1342 tests passed, 39 skipped on Windows. Typecheck/build passed. Additional file/lifecycle regression: 28 passed. Isolated ACP plus native plus localhost mock model covered 0.160.1 and CODEX_PATH 0.159.3; no real model or real user session was used. Independent focused review passed 6/6 reproductions, including real owned-child termination. The native file RPC E2E passed 9 checks (actual apply_patch, persisted replay, preview, stale/conflict refusal, index/unrelated-byte preservation and same-session continuation). These are scoped findings, not proof of every interleaving. diff --git a/docs/native-session-extensions.zh-CN.md b/docs/native-session-extensions.zh-CN.md new file mode 100644 index 000000000..8aa03e376 --- /dev/null +++ b/docs/native-session-extensions.zh-CN.md @@ -0,0 +1,31 @@ +# 本地原生会话扩展 + +本测试版在 main `51aa211` 上整合 PR #508,adapter 2.1.1,冻结 Codex 0.160.1、SDK 1.5.0。保留主线 hooks、附件回放、工具调用协议与压缩处理。没有改 Codeg、全局安装、push 或新 PR。 + +## 已接入 + +- `_session/rewind`:保留原接口和作者提交;同 ID 回退首条、历史、最近消息。验证指纹/保留边界,生成中先取消并等待旧 prompt 清理,再调用原生回退并核验持久前缀。 +- `_session/steering`:opt-in `_meta.steering.idleBehavior:"promptRequired"`,空闲返回 promptRequired,不偷偷启动模型。未 opt-in 保留旧行为。 +- runtime read:context、usage、mcp、commands(native skills)、plugins。未知用量保留 null,不捏造分类。 +- runtime control:reloadSkills、reconnectMcp、reloadPlugins;后两者要求 provider 空闲,确认回执不等于 runtimeReady。技能刷新后重发可用命令。 +- archive/unarchive:原生可逆归档、同 ID;不新增永久删除。旧 session/delete 仍保持原来的 archive 语义。 +- search:限定参数和 appServer/cli/vscode 来源;已验证当前 rollout synthetic 文本命中。回退后仅保留在祖先 rollout 的文本可能被原生搜索漏掉。 +- attachments:原生 thread 元数据 list/add/remove、冷重启持久化;不创建/删除磁盘文件、PR 或 worktree。 +- queue:只在真实 native 版本和无副作用探测通过后广告 list。写入/start 在 native mutation 前 unsupported;已有 durable pending queue 阻止 ACP load/resume,避免无人归属的自动生成。 +- files/revert:限定单个已完成 native fileChange 工具的 Git 文本补丁。preview token 绑定原生补丁与文件指纹,提交前复核;不改会话历史。 + +## 文件语义与恢复 + +文件撤销不等于 whole checkpoint:不覆盖 shell、未记录子代理、二进制补丁。Git reverse check/apply 不用 --index/--cached/--3way;用户 index 和无关文件严格保持。恢复后的文本走 Git 属性/autocrlf,不保证原始字节或换行完全一致。未知 change kind、缺失 diff、冲突、陈旧 token、越界/链接路径整批拒绝。 + +runtime mutation 30 秒超时后使会话失效并终止 provider,用户重连 ACP 再显式 load;不会自动重放 mutation。Windows 终止 owned 进程树;POSIX 当前仅保证 launcher 终止,子树级保证未验证。历史回退 dispatch 后失败可能已改变磁盘,必须成功 load 对账再继续发送。 + +## 尚未实现的 Desktop 能力 + +完整队列自动回合 observer/adoption、realtime voice、timeline、Desktop 项目/worktree/UI 全部管理、全工作区文件 checkpoint、二进制反向恢复、外部写入进程完全隔离、祖先 rollout 全文搜索均不宣称完成。native schema 存在某方法不等于本 ACP 已支持。 + +## 验证 + +Windows 完整套件 82 文件通过/8 跳过,1342 项通过/39 跳过;typecheck/build 通过;文件/lifecycle 增量 28 项通过。0.160.1 与 CODEX_PATH 0.159.3 的真实 ACP+native+localhost mock 模型隔离实验通过,未调用真实模型或用户会话。独立定向 review 六项复现全通过(含真实 owned-child 终止);独立文件 RPC E2E 九项通过,覆盖真实 apply_patch、持久回放、预览、陈旧/冲突拒绝、index/无关文件字节保持、同会话继续;仍不代表所有交错均已验证。 + +协议细节见 [English](native-session-extensions.md);原 rewind 作者 Nikita Ashikhmin,来源 [PR #508](https://github.com/agentclientprotocol/codex-acp/pull/508),四条原提交保留在历史中。 diff --git a/docs/session-rewind-extension.md b/docs/session-rewind-extension.md index 54df9e92b..cd21a7d2f 100644 --- a/docs/session-rewind-extension.md +++ b/docs/session-rewind-extension.md @@ -43,14 +43,25 @@ The request names the current ACP session and the first user message to remove: `beforeMessage` is excluded from the retained history. `resumeAtMessage` identifies the last visible assistant message to retain. It is absent when the client rewinds the first user turn. -Each history point contains the ACP message ID, the SHA-256 fingerprint of its complete text, and the one-based occurrence of that fingerprint for its role. The adapter uses the message ID first. It uses the fingerprint occurrence when restored provider history has different message IDs. +Each history point contains the ACP message ID, the SHA-256 fingerprint of its complete text, and the one-based occurrence of that fingerprint for its role. The adapter uses the message ID first and verifies the selected message fingerprint. It uses the fingerprint occurrence when restored provider history has different message IDs. Repeated fingerprint fallback requires a current `resumeAtMessage` boundary; fallback is refused when visible text omits attachment identity. A supplied retained boundary must identify the last retained assistant message. Reload rather than guessing when a point is stale. -The adapter returns `{ "rewound": true }` only after Codex accepts the rewind. A false response or an error leaves the client transcript unchanged. +The adapter returns `{ "rewound": true }` only after Codex accepts the rewind and a native history read verifies the same thread ID and retained prefix. Errors after mutation dispatch can have an unknown outcome: the client must load the session to reconcile before retrying or sending. The adapter blocks further prompt/rewind requests until that load succeeds. It never retries a mutation automatically. ## Codex mapping The adapter reads the existing Codex thread history and resolves `beforeMessage` to its containing turn. Rewind is rejected when the selected message is a steer inside an existing turn because Codex cannot remove only that suffix. For paginated history, the adapter calls `thread/revert` with the containing turn as the exclusive boundary; legacy history uses the equivalent turn-count rollback operation. -The Codex thread ID remains the ACP session ID. The adapter does not call `thread/fork`, create a thread, or add a session-list entry. `resumeAtMessage` is not needed after the turn-boundary validation. +The Codex thread ID remains the ACP session ID. The adapter does not call `thread/fork`, create a thread, or add a session-list entry. `resumeAtMessage` validates the retained boundary and disambiguates repeated fingerprint fallback. After a successful response, the client can remove the same transcript suffix and place the selected user text in its editor. + + +## Lifecycle and compatibility + +An active prompt is cancelled only after the target validates; the adapter waits for prompt cleanup before native mutation. A pending turn/start registration is rejected, because an unacknowledged native start could write after the rewind. Competing lifecycle/settings/steering/runtime operations are fenced. Provider replacement waits for an existing rewind. + +`thread/rollback` is used only when native history explicitly reports `legacy`. It is a separately typed compatibility request for old `CODEX_PATH` binaries, not part of current generated API types. Unknown history mode, arbitrary errors, malformed responses and `thread/revert` failures never trigger rollback. + +History rewind does not restore files. See [native session extensions](native-session-extensions.md) for the separate, previewed single-tool Git patch reversal. + +The interface and original implementation come from [Nikita Ashikhmin's PR #508](https://github.com/agentclientprotocol/codex-acp/pull/508), preserved as four commits in this branch. This local integration merges current main and adds validation/lifecycle safeguards; it is not a new incompatible rewind protocol. diff --git a/src/AcpExtensions.ts b/src/AcpExtensions.ts index cca4297fd..95363b81d 100644 --- a/src/AcpExtensions.ts +++ b/src/AcpExtensions.ts @@ -128,10 +128,11 @@ export async function legacySetSessionModel( export type SessionSteerRequest = { sessionId: SessionId; prompt: ContentBlock[]; + _meta?: {steering?: {idleBehavior?: "promptRequired"}}; } export type SessionSteeringResponse = { - outcome: "injected" | "startedNewTurn" | "failed"; + outcome: "injected" | "startedNewTurn" | "promptRequired" | "failed"; } export type SessionSteeringExtRequest = { diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index ce97c54bb..e84fca922 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -1,3 +1,4 @@ +import {probeSessionQueueSupport, type SessionQueueSupport} from "./SessionQueue"; import { type ApiKeyAuthRequest, CODEX_API_KEY_ENV_VAR, @@ -166,6 +167,8 @@ export class CodexAcpClient { return this.codexClient; } + queueSupport: SessionQueueSupport = {nativeVersion: null, actions: []}; + private readonly defaultClientInfo: ClientInfo = { name: `${packageJson.name}`, title: "Codex ACP", version: `${packageJson.version}` }; @@ -183,6 +186,10 @@ export class CodexAcpClient { } }); this.configPath = response?.codexHome ?? null; + // Native userAgent uses the requesting client name when Desktop originator env is absent. + // Its /version segment is the running native version, not clientInfo.version. + const version = response?.userAgent?.match(/^[^/]+\/(\d+\.\d+\.\d+)\s+\(/)?.[1]; + this.queueSupport = await probeSessionQueueSupport(version, action => action === "list" ? this.codexClient.probeQueueAction(action) : Promise.resolve(false)); } getHomePath(): string | null { @@ -678,8 +685,8 @@ export class CodexAcpClient { }); } - async rewindSession(request: SessionRewindRequest): Promise<{rewound: boolean}> { - const response = await runRewindSession(request, this.codexClient); + async rewindSession(request: SessionRewindRequest, hooks?: Parameters[2]): Promise<{rewound: boolean}> { + const response = await runRewindSession(request, this.codexClient, hooks); await this.waitForSessionNotifications(request.sessionId); return response; } @@ -791,6 +798,20 @@ export class CodexAcpClient { } } + async archiveSession(sessionId: string): Promise { + await this.codexClient.threadArchive({threadId: sessionId}); + } + async unarchiveSession(sessionId: string): Promise { + const response = await this.codexClient.threadUnarchive({threadId: sessionId}); + if (response.thread.id !== sessionId) throw new Error("Unarchive returned a different thread"); + } + async installedPlugins(cwds: string[]) { + return this.codexClient.pluginInstalled({cwds}); + } + async reconcilePlugins() { + return this.codexClient.pluginReconcile({reason: "ACP runtime refresh"}); + } + async deleteSession(sessionId: string): Promise { try { await this.codexClient.threadArchive({threadId: sessionId}); diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index ead7814d2..5694005fb 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -1,7 +1,14 @@ +import {terminateCodexConnection} from "./CodexJsonRpcConnection"; +import {fileRevertCapability, revertSessionFiles, type SessionFileRevertRequest} from "./SessionFileRevert"; +import {parseSessionQueueRequest, runSessionQueue, sessionQueueCapability, type SessionQueueRequest} from "./SessionQueue"; +import {sessionDiscoveryCapability, searchSessions, sessionAttachments, type SessionSearchRequest, type SessionAttachmentsRequest} from "./SessionDiscovery"; +import {archiveCapability} from "./SessionArchive"; import * as acp from "@agentclientprotocol/sdk"; import {RequestError, type SessionId, type SessionModeState} from "@agentclientprotocol/sdk"; import {CodexEventHandler, type CompletedPlan} from "./CodexEventHandler"; -import {attachmentFileUri, desktopAttachmentHistory} from "./DesktopAttachmentHistory"; +import {attachmentFileUri} from "./DesktopAttachmentHistory"; +import {userInputToContentBlocks} from "./UserInputContent"; +import {runtimeCapability, runtimeReadParser, runtimeControlParser, readRuntime, controlRuntime, type RuntimeReadRequest, type RuntimeControlRequest, type RuntimeResponse} from "./SessionRuntime"; import {CodexApprovalHandler} from "./permissions/CodexApprovalHandler"; import {PermissionLifecycleContext} from "./permissions/lifecycle"; import {CodexElicitationHandler} from "./CodexElicitationHandler"; @@ -86,6 +93,8 @@ import { type LegacySetSessionModelRequest, type LegacySetSessionModelResponse, SESSION_STEERING_METHOD, + SESSION_REWIND_METHOD, + type SessionRewindRequest, type SessionSteeringResponse, type SessionSteerRequest, } from "./AcpExtensions"; @@ -148,6 +157,7 @@ import { AIR_AGENT_FILE_CHANGE_REPORT_KEY, AIR_ASYNC_TASKS_KEY, AIR_CODEX_HOOKS_KEY, + AIR_SESSION_REWIND_KEY, AIR_DIFF_PATCH_KEY, AIR_NATIVE_SUBAGENT_SESSIONS_KEY, AIR_PLAN_CONTENT_DELTA_KEY, @@ -353,6 +363,14 @@ export class CodexAcpServer { private codexProcessGeneration = 0; private initializeRequest: acp.InitializeRequest | null = null; private providerUpdate: Promise | null = null; + private providerRuntimeControl = false; + private providerCreationReservations = 0; + private providerInvalidated = false; + private providerReads = 0; + private readonly runtimeControls = new Set(); + private readonly rewinds = new Map>(); + private readonly rewindRecoveryRequired = new Set(); + private readonly sessionOperations = new Map(); constructor( connection: AcpClientConnection, @@ -469,8 +487,14 @@ export class CodexAcpServer { }, authMethods: getCodexAuthMethods(_params.clientCapabilities), _meta: { + runtime: runtimeCapability(), + archive: archiveCapability(), + fileRevert: fileRevertCapability(), + discovery: sessionDiscoveryCapability(), + ...(sessionQueueCapability(this.codexAcpClient.queueSupport) ? {queue: sessionQueueCapability(this.codexAcpClient.queueSupport)} : {}), steering: { supported: true, + idleBehavior: ["promptRequired"], }, // Only AIR gets the AIR extension, see `docs/air-extensions.md`. ...(this.capabilities.airClient ? { @@ -488,6 +512,7 @@ export class CodexAcpServer { AIR_RAW_INPUT_RENDERING_KEY, AIR_PLAN_CONTENT_DELTA_KEY, AIR_CODEX_HOOKS_KEY, + AIR_SESSION_REWIND_KEY, ], }, }, @@ -496,6 +521,254 @@ export class CodexAcpServer { }; } + private assertProviderAvailable(): void { + if (this.providerInvalidated) throw RequestError.invalidRequest("Native provider was invalidated after a timeout; reconnect the ACP adapter"); + } + + private async invalidateProviderAfterTimeout(): Promise { + // Poison admission BEFORE disposing the transport: late native completions must never + // install or publish a session, even if process termination itself fails. + this.providerInvalidated = true; + for (const session of this.sessions.values()) { + session.asyncTasks.clear(); + this.bumpSessionGeneration(session.sessionId); + this.mcpSessionStartup.close(session.sessionId); + } + for (const prompt of this.activePrompts.values()) prompt.requestClose(); + for (const pending of this.pendingTurnStarts.values()) pending.resolve(null); + this.sessions.clear(); + this.pendingTurnStarts.clear(); + this.steeringQueues.clear(); + if (this.codexProcessState !== null) await terminateCodexConnection(this.codexProcessState.connection); + } + + private async boundedRuntimeControl(operation: () => Promise): Promise { + const pending = operation(); + // settledWithin observes the losing promise as well; it never becomes an unhandled rejection. + const result = await settledWithin(pending.then(value => ({value})), 30_000); + if (result === "pending") { + await this.invalidateProviderAfterTimeout(); + throw RequestError.internalError(undefined, "Native runtime operation timed out; provider terminated, reconnect the ACP adapter"); + } + return result.value; + } + + private assertNotRewinding(sessionId: string): void { + this.assertProviderAvailable(); + if (this.providerRuntimeControl || this.runtimeControls.has(sessionId)) throw RequestError.invalidRequest("Runtime control is in progress"); + if (this.rewinds.has(sessionId)) throw RequestError.invalidRequest(`Session ${sessionId} is rewinding`); + } + + private async sessionOperation(sessionId: string, operation: () => Promise): Promise { + while (this.providerUpdate !== null) await this.providerUpdate; + this.assertNotRewinding(sessionId); + this.sessionOperations.set(sessionId, (this.sessionOperations.get(sessionId) ?? 0) + 1); + try { return await operation(); } + finally { + const remaining = (this.sessionOperations.get(sessionId) ?? 1) - 1; + if (remaining === 0) this.sessionOperations.delete(sessionId); + else this.sessionOperations.set(sessionId, remaining); + } + } + + private async rewindSession(params: SessionRewindRequest): Promise<{rewound: boolean}> { + // A later provider restart waits for this operation; an earlier restart completes first. + while (this.providerUpdate !== null) await this.providerUpdate; + this.assertNotRewinding(params.sessionId); + const state = this.sessions.get(params.sessionId); + if (!state) throw RequestError.invalidParams(undefined, `Unknown session: ${params.sessionId}`); + if (this.rewindRecoveryRequired.has(params.sessionId)) { + throw RequestError.invalidRequest("Previous rewind outcome is uncertain; load the session before continuing"); + } + if (this.sessionIsClosing(params.sessionId) || this.sessionOperations.has(params.sessionId)) { + throw RequestError.invalidRequest("Session lifecycle or settings update is in progress"); + } + const prompt = this.activePrompts.get(params.sessionId); + // A turn/start that has not answered could write after the rewind. Do not synthesize + // completion or reuse closeSession's early-return path for this history mutation. + if (prompt && !prompt.currentTurn) throw RequestError.invalidRequest("Prompt startup is pending; cancel or retry after the turn starts"); + let release!: () => void; + this.rewinds.set(params.sessionId, new Promise(resolve => { release = resolve; })); + let mutationStarted = false; + try { + const result = await this.runWithProcessCheck(() => this.codexAcpClient.rewindSession(params, { + beforeMutation: async () => { + if (!prompt) return; + prompt.requestCancel(); + if (prompt.currentTurn) await this.requestTurnInterrupt(prompt.currentTurn, "Cancel"); + if (await settledWithin(prompt.completion, 10_000) === "pending") { + throw RequestError.invalidRequest("Prompt has not settled; rewind was not dispatched"); + } + await this.codexAcpClient.waitForSessionNotifications(params.sessionId); + }, + mutationStarted: () => { mutationStarted = true; }, + })); + // An old asynchronous title/goal publication must not describe the discarded suffix. + this.bumpSessionGeneration(params.sessionId); + state.lastTokenUsage = null; + state.totalTokenUsage = null; + delete state.sessionFailure; + return result; + } catch (error) { + // The storage cutover can succeed before native reload/response delivery fails. + // Never repeat the mutation or send against an unverified continuation. + if (mutationStarted) this.rewindRecoveryRequired.add(params.sessionId); + throw error; + } finally { + this.rewinds.delete(params.sessionId); + release(); + } + } + + async revertFiles(request: SessionFileRevertRequest) { + while (this.providerUpdate !== null) await this.providerUpdate; + this.assertNotRewinding(request.sessionId); + const state = this.getSessionState(request.sessionId); + if (this.providerCreationReservations || this.rewinds.size || this.sessionOperations.size || this.providerReads || this.activePrompts.size + || this.pendingTurnStarts.size || this.closingSessions.size) throw RequestError.invalidRequest("Provider is busy; settle work before reverting files"); + this.providerRuntimeControl = true; + try { + // Include native-loaded child threads, not just root ACP sessions. Fail closed if + // this native version cannot prove that its background terminals are idle. + await this.boundedRuntimeControl(async () => { + let cursor: string | null = null; + const seen = new Set(); + do { + const page = await this.codexAcpClient.appServerClient.threadLoadedList({cursor, limit: 100}); + for (const threadId of page.data) { + const thread = await this.codexAcpClient.appServerClient.threadRead({threadId}); + if (thread.thread.status.type === "active") throw RequestError.invalidRequest("Native thread is active; settle work before reverting files"); + const terminals = await this.codexAcpClient.appServerClient.threadBackgroundTerminalsList({threadId, limit: 1}); + if (terminals.data.length || terminals.nextCursor) throw RequestError.invalidRequest("Native background work is active"); + } + cursor = page.nextCursor; + if (cursor && seen.has(cursor)) throw RequestError.internalError(undefined, "Repeated loaded-thread cursor"); + if (cursor) seen.add(cursor); + } while (cursor); + }); + const result = await revertSessionFiles(request, state, () => this.boundedRuntimeControl(() => this.codexAcpClient.appServerClient.threadReadWithHistory(state.sessionId))); + if (result.reason === "restore_outcome_unknown") this.rewindRecoveryRequired.add(state.sessionId); + return result; + } finally { this.providerRuntimeControl = false; } + } + + private async assertNoPendingNativeQueue(sessionId: string): Promise { + if (!this.codexAcpClient.queueSupport.actions.includes("list")) return; + if (this.sessionIsClosing(sessionId)) throw RequestError.invalidRequest(`Session ${sessionId} is closing`); + const generation = this.getSessionGeneration(sessionId); + const client = this.codexAcpClient; + let page; + try { page = await client.appServerClient.queueNative().list({threadId: sessionId, limit: 1}); } + catch (error) { + // Preserve the existing public invalid-session diagnostic; this is a read validation + // error, never a reason to resume, mutate history or skip pending-queue checks. + if (error instanceof Error && error.message.startsWith("invalid thread id:")) { + throw RequestError.invalidParams(undefined, "invalid session id"); + } + throw error; + } + if (this.codexAcpClient !== client || this.getSessionGeneration(sessionId) !== generation || this.sessionIsClosing(sessionId)) { + throw RequestError.invalidRequest(`Session ${sessionId} changed while checking its native queue`); + } + if (page.data.length || page.nextCursor) throw RequestError.invalidRequest("Native pending queue requires an owning native client; ACP auto-dispatch is not supported"); + } + + async manageSessionQueue(value: unknown, signal?: AbortSignal): Promise { + const request = parseSessionQueueRequest(value); + while (this.providerUpdate !== null) await this.providerUpdate; + this.assertNotRewinding(request.sessionId); + this.getSessionState(request.sessionId); + const capability = sessionQueueCapability(this.codexAcpClient.queueSupport); + if (!capability?.actions.includes(request.action)) return {status: "unsupported"}; + if (this.sessionIsClosing(request.sessionId) || this.sessionOperations.has(request.sessionId)) throw RequestError.invalidRequest("Session is busy"); + return this.sessionOperation(request.sessionId, async () => { + this.runtimeControls.add(request.sessionId); + try { return await runSessionQueue(request, {sessionId: request.sessionId, support: this.codexAcpClient.queueSupport, native: this.codexAcpClient.appServerClient.queueNative()}); } + finally { this.runtimeControls.delete(request.sessionId); } + }); + } + + async searchSessionHistory(params: SessionSearchRequest) { + while (this.providerUpdate !== null) await this.providerUpdate; + if (this.providerRuntimeControl) throw RequestError.invalidRequest("Runtime control is in progress"); + this.providerReads++; + try { return await searchSessions(this.codexAcpClient.appServerClient, params); } + finally { this.providerReads--; } + } + + async manageSessionAttachments(params: SessionAttachmentsRequest) { + while (this.providerUpdate !== null) await this.providerUpdate; + return this.sessionOperation(params.sessionId, async () => { + const state = this.getSessionState(params.sessionId); + if (this.sessionIsClosing(params.sessionId)) throw RequestError.invalidRequest("Session is closing"); + this.runtimeControls.add(params.sessionId); + try { return await sessionAttachments(this.codexAcpClient.appServerClient, state.sessionId, params); } + finally { this.runtimeControls.delete(params.sessionId); } + }); + } + + async readSessionRuntime(request: RuntimeReadRequest, signal: AbortSignal = new AbortController().signal): Promise { + request = runtimeReadParser.parse(request); + while (this.providerUpdate !== null) await this.providerUpdate; + return this.sessionOperation(request.sessionId, async () => { + const state = this.getSessionState(request.sessionId); + const client = this.codexAcpClient; + const generation = this.getSessionGeneration(request.sessionId); + return readRuntime(client, state, request, signal, () => + this.codexAcpClient === client && this.sessionPublishIsCurrent(state, generation)); + }); + } + + async controlSessionRuntime(request: RuntimeControlRequest): Promise { + request = runtimeControlParser.parse(request); + while (this.providerUpdate !== null) await this.providerUpdate; + this.assertNotRewinding(request.sessionId); + const state = this.getSessionState(request.sessionId); + if (request.action === "reconnectMcp" || request.action === "reloadPlugins") { + if (this.providerCreationReservations || this.rewinds.size || this.sessionOperations.size || this.providerReads || this.activePrompts.size + || this.pendingTurnStarts.size || this.closingSessions.size) { + throw RequestError.invalidRequest("Provider is busy; settle its sessions before reconnecting MCP"); + } + this.providerRuntimeControl = true; + try { return await this.boundedRuntimeControl(() => this.runWithProcessCheck(() => controlRuntime(this.codexAcpClient, state, request))); } + finally { this.providerRuntimeControl = false; } + } + if (this.sessionOperations.has(request.sessionId) || this.sessionIsClosing(request.sessionId)) { + throw RequestError.invalidRequest("Session lifecycle or settings update is in progress"); + } + return this.sessionOperation(request.sessionId, async () => { + this.runtimeControls.add(request.sessionId); + try { + return await this.boundedRuntimeControl(async () => { + const result = await this.runWithProcessCheck(() => controlRuntime(this.codexAcpClient, state, request)); + this.assertProviderAvailable(); + await this.publishAvailableCommands(state, this.getSessionGeneration(request.sessionId)); + return result; + }); + } finally { this.runtimeControls.delete(request.sessionId); } + }); + } + + async archiveSession(sessionId: string, archived: boolean): Promise<{sessionId: string; archived: boolean}> { + while (this.providerUpdate !== null) await this.providerUpdate; + this.assertNotRewinding(sessionId); + if (this.sessionOperations.has(sessionId) || this.activePrompts.has(sessionId) || this.pendingTurnStarts.has(sessionId) + || this.sessionIsClosing(sessionId)) throw RequestError.invalidRequest("Session is busy; settle work before archiving"); + // Hold a named fence until native archive/unarchive completes. closeSession is cleanup, + // not a writer-lock handoff: the same app-server performs the archive. + return this.sessionOperation(sessionId, async () => { + this.beginSessionCloseFence(sessionId); + try { + if (this.hasLocalSession(sessionId) && this.sessions.has(sessionId)) await this.closeSessionDuringOperation({sessionId}); + await this.runWithProcessCheck(() => archived + ? this.codexAcpClient.archiveSession(sessionId) + : this.codexAcpClient.unarchiveSession(sessionId)); + this.rewindRecoveryRequired.delete(sessionId); + return {sessionId, archived}; + } finally { this.endSessionCloseFence(sessionId); } + }); + } + async listHooks(cwd: string): Promise { if (this.providerUpdate !== null) await this.providerUpdate; return await this.runWithProcessCheck(() => listCodexHooks(this.codexAcpClient.appServerClient, cwd)); @@ -507,6 +780,13 @@ export class CodexAcpServer { } async extMethod(method: string, params: Record): Promise> { + if ((method === GOAL_CONTROL_METHOD || method === LEGACY_GOAL_CONTROL_METHOD) && typeof params["sessionId"] === "string") { + return this.sessionOperation(params["sessionId"], () => this.extMethodDuringOperation(method, params)); + } + return this.extMethodDuringOperation(method, params); + } + + private async extMethodDuringOperation(method: string, params: Record): Promise> { const methodRequest = { method: method, params: params }; if (!isExtMethodRequest(methodRequest)) { return {}; @@ -534,6 +814,8 @@ export class CodexAcpServer { ), }; } + case SESSION_REWIND_METHOD: + return await this.rewindSession(methodRequest.params); case GOAL_CONTROL_METHOD: case LEGACY_GOAL_CONTROL_METHOD: { const sessionState = this.sessions.get(methodRequest.params.sessionId); @@ -1019,6 +1301,7 @@ export class CodexAcpServer { } private installSessionState(sessionState: SessionState): void { + this.assertProviderAvailable(); this.sessions.get(sessionState.sessionId)?.asyncTasks.clear(); this.sessions.set(sessionState.sessionId, sessionState); } @@ -1031,6 +1314,15 @@ export class CodexAcpServer { } async loadSession(params: acp.LoadSessionRequest): Promise { + return this.sessionOperation(params.sessionId, async () => { + const result = await this.loadSessionDuringOperation(params); + this.rewindRecoveryRequired.delete(params.sessionId); + return result; + }); + } + + private async loadSessionDuringOperation(params: acp.LoadSessionRequest): Promise { + if (this.codexAcpClient.queueSupport.actions.includes("list")) await this.assertNoPendingNativeQueue(params.sessionId); if (this.providerUpdate !== null) { await this.providerUpdate; } @@ -1079,6 +1371,14 @@ export class CodexAcpServer { } async resumeSession(params: acp.ResumeSessionRequest): Promise { + return this.sessionOperation(params.sessionId, async () => { + const result = await this.resumeSessionDuringOperation(params); + return result; + }); + } + + private async resumeSessionDuringOperation(params: acp.ResumeSessionRequest): Promise { + if (this.codexAcpClient.queueSupport.actions.includes("list")) await this.assertNoPendingNativeQueue(params.sessionId); if (this.providerUpdate !== null) { await this.providerUpdate; } @@ -1098,6 +1398,13 @@ export class CodexAcpServer { } async forkSession(params: acp.ForkSessionRequest): Promise { + return this.sessionOperation(params.sessionId, async () => { + const result = await this.forkSessionDuringOperation(params); + return result; + }); + } + + private async forkSessionDuringOperation(params: acp.ForkSessionRequest): Promise { if (this.providerUpdate !== null) { await this.providerUpdate; } @@ -1137,6 +1444,14 @@ export class CodexAcpServer { } async closeSession(params: acp.CloseSessionRequest): Promise { + if (this.providerInvalidated) return {}; + return this.sessionOperation(params.sessionId, async () => { + const result = await this.closeSessionDuringOperation(params); + return result; + }); + } + + private async closeSessionDuringOperation(params: acp.CloseSessionRequest): Promise { logger.log("Closing session...", {sessionId: params.sessionId}); const closeGeneration = this.bumpSessionGeneration(params.sessionId); const sessionState = this.sessions.get(params.sessionId); @@ -1175,6 +1490,13 @@ export class CodexAcpServer { } async deleteSession(params: acp.DeleteSessionRequest): Promise { + return this.sessionOperation(params.sessionId, async () => { + const result = await this.deleteSessionDuringOperation(params); + return result; + }); + } + + private async deleteSessionDuringOperation(params: acp.DeleteSessionRequest): Promise { logger.log("Deleting session...", {sessionId: params.sessionId}); const sessionId = params.sessionId; const shouldCloseLocalSession = this.hasLocalSession(sessionId); @@ -1212,9 +1534,13 @@ export class CodexAcpServer { async newSession( params: acp.NewSessionRequest, ): Promise { - if (this.providerUpdate !== null) { - await this.providerUpdate; - } + this.assertProviderAvailable(); + if (this.providerRuntimeControl) throw RequestError.invalidRequest("Runtime control is in progress"); + this.providerCreationReservations++; + try { + while (this.providerUpdate !== null) await this.providerUpdate; + this.assertProviderAvailable(); + if (this.providerRuntimeControl) throw RequestError.invalidRequest("Runtime control is in progress"); logger.log("Starting new session..."); const [sessionId, modelState, modeState] = await this.getOrCreateSession(params); @@ -1230,12 +1556,15 @@ export class CodexAcpServer { modes: modeState, ...this.createSessionConfigOptionsResponse(this.getSessionState(sessionId)), }; + } finally { this.providerCreationReservations--; } } async authenticate( _params: acp.AuthenticateRequest, requestId?: acp.JsonRpcId, ): Promise { + this.assertProviderAvailable(); + if (this.providerCreationReservations || this.providerRuntimeControl || this.rewinds.size > 0) throw RequestError.invalidRequest("Provider history or runtime operation is in progress"); logger.log("Authenticate request received"); const elicitationRequester = this.createUrlElicitationRequester(requestId); const isAuthenticated = await this.runWithProcessCheck(() => this.codexAcpClient.authenticate(_params, elicitationRequester)); @@ -1274,6 +1603,8 @@ export class CodexAcpServer { } async logout(_params: acp.LogoutRequest): Promise { + this.assertProviderAvailable(); + if (this.providerCreationReservations || this.providerRuntimeControl || this.rewinds.size > 0) throw RequestError.invalidRequest("Provider history or runtime operation is in progress"); logger.log("Logout request received"); await this.runWithProcessCheck(() => this.codexAcpClient.logout()); await this.refreshAuthState(null); @@ -1285,12 +1616,22 @@ export class CodexAcpServer { } async setProvider(params: acp.SetProviderRequest): Promise { + this.assertProviderAvailable(); + if (this.providerRuntimeControl || this.providerCreationReservations) throw RequestError.invalidRequest("Runtime control or session creation is in progress"); + while (this.rewinds.size > 0) await Promise.all([...this.rewinds.values()]); + if (this.providerRuntimeControl) throw RequestError.invalidRequest("Runtime control is in progress"); + if (this.providerCreationReservations || this.sessionOperations.size > 0 || this.providerReads > 0) throw RequestError.invalidRequest("A session lifecycle or settings operation is in progress"); this.codexAcpClient.setProvider(params); await this.enqueueProviderUpdate((client) => client.setProvider(params)); return { }; } async disableProvider(params: acp.DisableProviderRequest): Promise { + this.assertProviderAvailable(); + if (this.providerRuntimeControl || this.providerCreationReservations) throw RequestError.invalidRequest("Runtime control or session creation is in progress"); + while (this.rewinds.size > 0) await Promise.all([...this.rewinds.values()]); + if (this.providerRuntimeControl) throw RequestError.invalidRequest("Runtime control is in progress"); + if (this.providerCreationReservations || this.sessionOperations.size > 0 || this.providerReads > 0) throw RequestError.invalidRequest("A session lifecycle or settings operation is in progress"); this.codexAcpClient.disableProvider(params); if (params.providerId !== OPENAI_PROVIDER_ID) { return { }; @@ -1330,6 +1671,10 @@ export class CodexAcpServer { for (const session of this.sessions.values()) { session.asyncTasks.setAppServer(replacement.appServerClient); try { + if (replacement.queueSupport.actions.includes("list")) { + const pending = await replacement.appServerClient.queueNative().list({threadId: session.sessionId, limit: 1}); + if (pending.data.length || pending.nextCursor) throw RequestError.invalidRequest("Native pending queue cannot be auto-resumed by ACP"); + } await replacement.resumeSession({ sessionId: session.sessionId, cwd: session.cwd, @@ -1599,6 +1944,8 @@ export class CodexAcpServer { async setSessionMode( _params: acp.SetSessionModeRequest, ): Promise { + while (this.providerUpdate !== null) await this.providerUpdate; + this.assertNotRewinding(_params.sessionId); logger.log("Set session mode requested", { sessionId: _params.sessionId, modeId: _params.modeId @@ -1611,6 +1958,13 @@ export class CodexAcpServer { } async setSessionConfigOption(params: acp.SetSessionConfigOptionRequest): Promise { + return this.sessionOperation(params.sessionId, async () => { + const result = await this.setSessionConfigOptionDuringOperation(params); + return result; + }); + } + + private async setSessionConfigOptionDuringOperation(params: acp.SetSessionConfigOptionRequest): Promise { logger.log("Set session config option requested", { sessionId: params.sessionId, configId: params.configId, @@ -1715,6 +2069,13 @@ export class CodexAcpServer { } async unstable_setSessionModel(params: LegacySetSessionModelRequest): Promise { + return this.sessionOperation(params.sessionId, async () => { + const result = await this.unstable_setSessionModelDuringOperation(params); + return result; + }); + } + + private async unstable_setSessionModelDuringOperation(params: LegacySetSessionModelRequest): Promise { logger.log("Set session model requested", { sessionId: params.sessionId, modelId: params.modelId @@ -1775,6 +2136,14 @@ export class CodexAcpServer { * {@link performSteeringRequest}. */ async executeOrQueueSteeringRequest(params: SessionSteerRequest): Promise { + return this.sessionOperation(params.sessionId, async () => { + const result = await this.executeOrQueueSteeringRequestDuringOperation(params); + return result; + }); + } + + private async executeOrQueueSteeringRequestDuringOperation(params: SessionSteerRequest): Promise { + if (this.rewindRecoveryRequired.has(params.sessionId)) throw RequestError.invalidRequest("Previous rewind outcome is uncertain; load the session before continuing"); const queue = this.getSteeringQueue(params.sessionId); try { return await queue.enqueue(params); @@ -1831,6 +2200,7 @@ export class CodexAcpServer { return {outcome: "injected"}; } } + if (params._meta?.steering?.idleBehavior === "promptRequired") return {outcome: "promptRequired"}; return await this.startNewTurnFromSteering(params); } @@ -2011,9 +2381,14 @@ export class CodexAcpServer { if (typeof sessionId !== "string" || !Array.isArray(prompt)) { throw RequestError.invalidParams(); } + const meta = params["_meta"]; + const steering = typeof meta === "object" && meta !== null && "steering" in meta ? meta.steering : undefined; + const idleBehavior = typeof steering === "object" && steering !== null && "idleBehavior" in steering ? steering.idleBehavior : undefined; + if (idleBehavior !== undefined && idleBehavior !== "promptRequired") throw RequestError.invalidParams(undefined, "Unknown steering idle behavior"); return { - sessionId: sessionId, + sessionId, prompt: prompt as acp.ContentBlock[], + ...(idleBehavior === "promptRequired" ? {_meta: {steering: {idleBehavior}}} : {}), }; } @@ -2114,7 +2489,7 @@ export class CodexAcpServer { } private sessionPublishIsCurrent(sessionState: SessionState, sessionGeneration: number): boolean { - return this.sessions.get(sessionState.sessionId) === sessionState + return !this.providerInvalidated && this.sessions.get(sessionState.sessionId) === sessionState && this.getSessionGeneration(sessionState.sessionId) === sessionGeneration && !this.sessionIsClosing(sessionState.sessionId); } @@ -2602,7 +2977,7 @@ export class CodexAcpServer { private createUserMessageUpdates(item: ThreadItem & { type: "userMessage" }): UpdateSessionEvent[] { const updates: UpdateSessionEvent[] = []; const messageId = item.id; - const contentBlocks = item.content.map(input => this.userInputToContentBlocks(input)); + const contentBlocks = item.content.map(input => userInputToContentBlocks(input)); const attachmentUris = new Set(contentBlocks.flatMap((blocks, index) => item.content[index]?.type === "text" ? blocks.flatMap(block => block.type === "resource_link" ? [block.uri] : []) @@ -2659,47 +3034,6 @@ export class CodexAcpServer { ); } - private userInputToContentBlocks(input: UserInput): acp.ContentBlock[] { - switch (input.type) { - case "text": - return desktopAttachmentHistory(input.text) - ?? (input.text.length > 0 ? [{ type: "text", text: input.text }] : []); - case "image": - return [{ - type: "text", - text: "url" in input - ? this.formatUriAsLink("image", input.url) - : `image:${input.fileId}`, - }]; - case "localImage": - case "localAudio": - case "mention": { - const uri = attachmentFileUri(input.path); - const fileName = input.path.split(/[\\/]/).pop() || input.type; - const name = input.type === "mention" && input.name.trim().length > 0 ? input.name : fileName; - return uri !== null - ? [{type: "resource_link", name, uri}] - : [{type: "text", text: this.formatUriAsLink(name, input.path)}]; - } - case "skill": - return [{ type: "text", text: `skill:${input.name} (${input.path})` }]; - case "audio": - return [{type: "text", text: this.formatUriAsLink("audio", input.url)}]; - } - } - - private formatUriAsLink(name: string | null, uri: string): string { - if (name && name.length > 0) { - return `[@${name}](${uri})`; - } - if (uri.startsWith("file://")) { - const path = uri.replace("file://", ""); - const fileName = path.split("/").pop() ?? path; - return `[@${fileName}](${uri})`; - } - return uri; - } - getSessionState(sessionId: string): SessionState { const sessionState = this.sessions.get(sessionId); if (!sessionState) { @@ -2971,6 +3305,12 @@ export class CodexAcpServer { if (this.providerUpdate !== null) { await this.providerUpdate; } + this.assertNotRewinding(params.sessionId); + if (this.rewindRecoveryRequired.has(params.sessionId)) { + throw RequestError.invalidRequest("Previous rewind outcome is uncertain; load the session before continuing"); + } + if (this.sessionIsClosing(params.sessionId)) throw RequestError.invalidRequest("Session is closing"); + if (this.activePrompts.has(params.sessionId)) throw RequestError.invalidRequest("Prompt is already running"); logger.log("Prompt received", { sessionId: params.sessionId, prompt: params.prompt, @@ -3551,6 +3891,7 @@ export class CodexAcpServer { } private async runWithProcessCheck(operation: () => Promise): Promise { + this.assertProviderAvailable(); try { return await operation(); } catch (err) { diff --git a/src/CodexAppServerClient.ts b/src/CodexAppServerClient.ts index 1b86c3542..bd56eadec 100644 --- a/src/CodexAppServerClient.ts +++ b/src/CodexAppServerClient.ts @@ -1,3 +1,7 @@ +import type {SessionQueueAction, SessionQueueNative} from "./SessionQueue"; +import type {NativeThreadSearchParams, NativeThreadSearchResponse} from "./SessionDiscovery"; +import type {ThreadAttachmentListParams, ThreadAttachmentListResponse, ThreadAttachmentAddParams, ThreadAttachmentAddResponse, ThreadAttachmentRemoveParams, ThreadAttachmentRemoveResponse} from "./app-server/v2"; +import type {ThreadUnarchiveParams, ThreadUnarchiveResponse, PluginInstalledParams, PluginInstalledResponse, PluginReconcileParams, PluginReconcileResponse} from "./app-server/v2"; import {type MessageConnection, RequestType} from "vscode-jsonrpc/node"; import {McpOauthCompletions} from "./mcp/McpOauthCompletions"; import {McpStartupTracker} from "./mcp/McpStartupTracker"; @@ -70,8 +74,6 @@ import type { ThreadResumeResponse, ThreadRevertParams, ThreadRevertResponse, - ThreadRollbackParams, - ThreadRollbackResponse, ThreadSettings, ThreadStartParams, ThreadStartResponse, @@ -733,6 +735,49 @@ export class CodexAppServerClient { } } + /** Invalid parameters only: proves method registration without a valid mutation target. */ + async probeQueueAction(action: SessionQueueAction): Promise { + try { await this.connection.sendRequest(`thread/queue/${action}`, {}); return false; } + catch (error) { + return typeof error === "object" && error !== null && "code" in error && error.code === -32600 + && "message" in error && error.message === "Invalid request: missing field `threadId`"; + } + } + + queueNative(): SessionQueueNative { + return { + list: params => this.connection.sendRequest("thread/queue/list", params), + add: params => this.connection.sendRequest("thread/queue/add", params), + update: params => this.connection.sendRequest("thread/queue/update", params), + delete: params => this.connection.sendRequest("thread/queue/delete", params), + reorder: params => this.connection.sendRequest("thread/queue/reorder", params), + start: params => this.connection.sendRequest("thread/queue/start", params), + }; + } + + async threadSearch(params: NativeThreadSearchParams): Promise { + return this.connection.sendRequest("thread/search", {...params, sourceKinds: ["appServer", "cli", "vscode"]}); + } + async threadAttachmentList(params: ThreadAttachmentListParams): Promise { + return this.sendRequest({method: "thread/attachment/list", params}); + } + async threadAttachmentAdd(params: ThreadAttachmentAddParams): Promise { + return this.sendRequest({method: "thread/attachment/add", params}); + } + async threadAttachmentRemove(params: ThreadAttachmentRemoveParams): Promise { + return this.sendRequest({method: "thread/attachment/remove", params}); + } + + async threadUnarchive(params: ThreadUnarchiveParams): Promise { + return this.sendRequest({method: "thread/unarchive", params}); + } + async pluginInstalled(params: PluginInstalledParams): Promise { + return this.sendRequest({method: "plugin/installed", params}); + } + async pluginReconcile(params: PluginReconcileParams): Promise { + return this.sendRequest({method: "plugin/reconcile", params}); + } + async threadArchive(params: ThreadArchiveParams): Promise { return await this.sendRequest({ method: "thread/archive", params: params }); } @@ -1141,7 +1186,11 @@ export type CompactionCompletedNotification = | { method: "thread/compacted", params: Extract["params"] } | { method: "item/completed", params: ItemCompletedNotification & { item: Extract } }; +// Compatibility for older CODEX_PATH binaries only. Current generated API removed rollback. +type ThreadRollbackParams = {threadId: string; numTurns: number}; +type ThreadRollbackResponse = {thread: ThreadReadResponse["thread"]}; type CodexRequest = DistributiveOmit | ThreadBackgroundTerminalsRequest + | {method: "thread/rollback"; params: ThreadRollbackParams}; type DistributiveOmit = T extends any ? Omit diff --git a/src/CodexJsonRpcConnection.ts b/src/CodexJsonRpcConnection.ts index 207f075d9..e7b98f65e 100644 --- a/src/CodexJsonRpcConnection.ts +++ b/src/CodexJsonRpcConnection.ts @@ -66,3 +66,26 @@ function attachLogs(proc: ChildProcessWithoutNullStreams) { logger.log(`[EXIT] code: ${code?.toString()}`); }); } + +/** Stops an unresponsive owned provider before allowing the client to reconnect. */ +export async function terminateCodexConnection(native: CodexConnection): Promise { + native.connection.dispose(); + const child = native.process; + if (child.exitCode !== null) return; + const exited = new Promise(resolve => child.once("exit", () => resolve())); + if (process.platform === "win32" && child.pid !== undefined) { + // The bundled launcher is Node with a native child; kill the owned tree, not only Node. + const killer = spawn("taskkill", ["/PID", String(child.pid), "/T", "/F"], {windowsHide: true, stdio: "ignore"}); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { killer.kill(); reject(new Error("Provider tree termination timed out")); }, 5_000); + killer.once("error", error => {clearTimeout(timer); reject(error);}); + killer.once("exit", code => {clearTimeout(timer); code === 0 || child.exitCode !== null ? resolve() : reject(new Error("Provider tree termination failed"));}); + }); + } else { + child.kill("SIGKILL"); + } + let timer: ReturnType | undefined; + try { + await Promise.race([exited, new Promise((_, reject) => {timer = setTimeout(() => reject(new Error("Provider did not exit")), 5_000);})]); + } finally {if (timer) clearTimeout(timer);} +} diff --git a/src/DesktopAttachmentHistory.ts b/src/DesktopAttachmentHistory.ts index 932ccd473..dbd093b49 100644 --- a/src/DesktopAttachmentHistory.ts +++ b/src/DesktopAttachmentHistory.ts @@ -20,7 +20,7 @@ export function attachmentFileUri(path: string): string | null { return null; } } - return path.startsWith("/") ? pathToFileURL(path).href : null; + return path.startsWith("/") ? pathToFileURL(path, {windows: false}).href : null; } /** Converts the known Desktop attachment envelope into history content blocks. */ diff --git a/src/SessionArchive.ts b/src/SessionArchive.ts new file mode 100644 index 000000000..0884b377f --- /dev/null +++ b/src/SessionArchive.ts @@ -0,0 +1,7 @@ +import {z} from "zod"; +export const SESSION_ARCHIVE_METHOD = "_session/archive"; +export const SESSION_UNARCHIVE_METHOD = "_session/unarchive"; +export const sessionArchiveParser = z.object({sessionId: z.string().trim().min(1)}).strict(); +export function archiveCapability() { + return {version: 1, archiveMethod: SESSION_ARCHIVE_METHOD, unarchiveMethod: SESSION_UNARCHIVE_METHOD, permanentDelete: false}; +} diff --git a/src/SessionDiscovery.ts b/src/SessionDiscovery.ts new file mode 100644 index 000000000..c4c1f2b71 --- /dev/null +++ b/src/SessionDiscovery.ts @@ -0,0 +1,73 @@ +import {RequestError} from "@agentclientprotocol/sdk"; +import {z} from "zod"; +import type {Thread, ThreadAttachmentAddParams, ThreadAttachmentAddResponse, ThreadAttachmentListParams, ThreadAttachmentListResponse, ThreadAttachmentRemoveParams, ThreadAttachmentRemoveResponse} from "./app-server/v2"; + +export const SESSION_SEARCH_METHOD = "_session/search"; +export const SESSION_ATTACHMENTS_METHOD = "_session/attachments"; +const identifier = z.string().min(1).max(4096).refine(value => value.trim().length > 0 && !Array.from(value).some(char => char.charCodeAt(0) < 32)); +const page = {cursor: identifier.nullable().optional(), limit: z.number().int().min(1).max(100).optional()}; +export const sessionSearchParser = z.object({ + searchTerm: z.string().trim().min(1).max(4096), + archived: z.boolean().optional(), + ...page, +}).strict(); +export type SessionSearchRequest = z.infer; +// Typed experimental method omitted from the package's generated subset. Shape +// verified against native 0.160.1 and the public app-server protocol source. +export type NativeThreadSearchParams = SessionSearchRequest; +export type NativeThreadSearchResponse = {data: Array<{thread: Thread, snippet: string}>, nextCursor: string | null, backwardsCursor?: string | null}; + +const attachmentIdentity = {sessionId: identifier, attachmentType: identifier, identityKey: identifier}; +const boundedJson = z.json().refine(value => Buffer.byteLength(JSON.stringify(value), "utf8") <= 256 * 1024, "Attachment payload exceeds 256 KiB"); +export const sessionAttachmentsParser = z.discriminatedUnion("action", [ + z.object({sessionId: identifier, action: z.literal("list"), ...page}).strict(), + z.object({...attachmentIdentity, action: z.literal("add"), payload: boundedJson}).strict(), + z.object({...attachmentIdentity, action: z.literal("remove")}).strict(), +]); +export type SessionAttachmentsRequest = z.infer; +export type SessionDiscoveryClient = { + threadSearch(params: NativeThreadSearchParams): Promise; + threadAttachmentList(params: ThreadAttachmentListParams): Promise; + threadAttachmentAdd(params: ThreadAttachmentAddParams): Promise; + threadAttachmentRemove(params: ThreadAttachmentRemoveParams): Promise; +}; + +export function sessionDiscoveryCapability() { + return { + version: 1, + searchMethod: SESSION_SEARCH_METHOD, + attachmentMethod: SESSION_ATTACHMENTS_METHOD, + attachmentActions: ["list", "add", "remove"], + attachmentsAreMetadata: true, + }; +} + +/** Search is native text search, not a model prompt or a local scan of files. */ +export async function searchSessions(client: Pick, params: SessionSearchRequest) { + const response = await client.threadSearch(sessionSearchParser.parse(params)); + return { + version: 1, + sessions: response.data.map(({thread, snippet}) => ({ + sessionId: thread.id, cwd: thread.cwd, title: thread.name ?? thread.preview, + updatedAt: thread.updatedAt, snippet, + })), + nextCursor: response.nextCursor, + }; +} + +/** These are independently persisted native thread attachments (PR links, + * worktree associations, etc.), not prompt images or disk operations. The host + * remains responsible for actually creating/removing any associated resource. */ +export async function sessionAttachments( + client: Omit, + sessionId: string, + params: SessionAttachmentsRequest, +): Promise<{version: 1, data: unknown}> { + params = sessionAttachmentsParser.parse(params); + if (params.sessionId !== sessionId) throw RequestError.invalidParams(undefined, "Session mismatch"); + switch (params.action) { + case "list": return {version: 1, data: await client.threadAttachmentList({threadId: sessionId, ...(params.cursor === undefined ? {} : {cursor: params.cursor}), ...(params.limit === undefined ? {} : {limit: params.limit})})}; + case "add": return {version: 1, data: await client.threadAttachmentAdd({threadId: sessionId, attachmentType: params.attachmentType, identityKey: params.identityKey, payload: params.payload})}; + case "remove": return {version: 1, data: await client.threadAttachmentRemove({threadId: sessionId, attachmentType: params.attachmentType, identityKey: params.identityKey})}; + } +} diff --git a/src/SessionFileRevert.ts b/src/SessionFileRevert.ts new file mode 100644 index 000000000..5ba556d2a --- /dev/null +++ b/src/SessionFileRevert.ts @@ -0,0 +1,300 @@ +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { lstat, readFile, realpath } from "node:fs/promises"; +import path from "node:path"; +import { RequestError } from "@agentclientprotocol/sdk"; +import { z } from "zod"; +import type { Thread, ThreadItem } from "./app-server/v2"; +import { + createAddedFileGitPatch, + createDeletedFileGitPatch, + createUpdateGitPatch, +} from "./GitPatch"; + +export const SESSION_FILE_REVERT_METHOD = "_session/files/revert"; +export const sessionFileRevertParser = z + .object({ + sessionId: z.string().trim().min(1), + toolCallId: z.string().trim().min(1), + dryRun: z.boolean(), + previewToken: z + .string() + .regex(/^sha256:[a-f0-9]{64}$/) + .optional(), + }) + .strict() + .refine( + (p) => p.dryRun || p.previewToken !== undefined, + "A restore requires its preview token", + ); +export type SessionFileRevertRequest = z.infer; +type NativeFileChange = Extract; +export type FileRevertSession = { + sessionId: string; + cwd: string; + additionalDirectories: string[]; +}; +export type FileRevertResult = { + version: 1; + dryRun: boolean; + canRevert: boolean; + reverted: boolean; + paths: string[]; + previewToken?: string; + reason?: string; +}; +export function fileRevertCapability() { + return { + version: 1, + method: SESSION_FILE_REVERT_METHOD, + dryRun: true, + previewTokenRequired: true, + scope: "nativeFileChangeTool", + requiresGit: true, + changesConversation: false, + coverage: + "Recorded text patches only; not shell changes or a whole-workspace checkpoint", + }; +} + +const hash = (value: string | Buffer) => + createHash("sha256").update(value).digest("hex"); +const MAX_BYTES = 8 * 1024 * 1024; +function inside(root: string, candidate: string): boolean { + const relative = path.relative(root, candidate); + return ( + relative !== "" && + !path.isAbsolute(relative) && + relative !== ".." && + !relative.startsWith(`..${path.sep}`) + ); +} + +/** Ignore inherited alternate-index/worktree knobs. This operation never stages + * files and never changes HEAD, branches, hooks, or the user's Git index. */ +async function git( + cwd: string, + args: string[], + input?: string, +): Promise<{ code: number | null; stdout: string }> { + const env = Object.fromEntries( + Object.entries(process.env).filter( + ([key]) => !key.toUpperCase().startsWith("GIT_"), + ), + ); + return new Promise((resolve, reject) => { + const child = spawn("git", args, { + cwd, + env, + windowsHide: true, + stdio: ["pipe", "pipe", "pipe"], + }); + const chunks: Buffer[] = []; + let size = 0; + const timer = setTimeout(() => child.kill(), 15_000); + child.stdout.on("data", (chunk: Buffer) => { + size += chunk.length; + if (size <= MAX_BYTES) chunks.push(chunk); + else child.kill(); + }); + child.stderr.resume(); + child.stdin.on("error", () => {}); + child.once("error", (error) => { + clearTimeout(timer); + reject(error); + }); + child.once("close", (code) => { + clearTimeout(timer); + resolve({ code, stdout: Buffer.concat(chunks).toString("utf8") }); + }); + child.stdin.end(input); + }); +} + +async function checkedPath( + root: string, + roots: string[], + value: string, +): Promise<{ absolute: string; relative: string }> { + const absolute = path.resolve(value); + if ( + !inside(root, absolute) || + !roots.some((workspace) => inside(workspace, absolute)) + ) + throw new Error("outside_workspace"); + const relative = path.relative(root, absolute); + const parts = relative.split(path.sep); + if (parts.some((part) => part.toLowerCase() === ".git" || part.includes(":"))) + throw new Error("unsafe_path"); + let walk = root; + for (let i = 0; i < parts.length; i++) { + walk = path.join(walk, parts[i]!); + try { + const stat = await lstat(walk); + if ( + stat.isSymbolicLink() || + (i < parts.length - 1 ? !stat.isDirectory() : !stat.isFile()) + ) + throw new Error("unsupported_file_type"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") break; + throw error; + } + } + return { absolute, relative: relative.split(path.sep).join("/") }; +} + +async function fingerprintFiles(files: string[]): Promise { + return Promise.all( + files.map(async (file) => { + try { + const stat = await lstat(file); + if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_BYTES) + throw new Error("unsupported_file_type_or_size"); + return `${file}\0${stat.mode}\0${hash(await readFile(file))}`; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") + return `${file}\0missing`; + throw error; + } + }), + ); +} + +/** Hold the session AND overlapping-workspace mutation fence around this whole + * call. External editors are not controlled by the adapter; Git rejects patch + * conflicts, but this is not a cross-process filesystem transaction. */ +export async function revertSessionFiles( + value: unknown, + session: FileRevertSession, + readHistory: () => Promise<{ thread: Thread }>, +): Promise { + const request = sessionFileRevertParser.parse(value); + if (request.sessionId !== session.sessionId) + throw RequestError.invalidParams(undefined, "Session mismatch"); + const base: FileRevertResult = { + version: 1, + dryRun: request.dryRun, + canRevert: false, + reverted: false, + paths: [], + }; + const { thread } = await readHistory(); + if (thread.id !== session.sessionId) + throw RequestError.invalidParams(undefined, "History session mismatch"); + const matches = thread.turns + .flatMap((turn) => turn.items) + .filter( + (item): item is NativeFileChange => + item.type === "fileChange" && item.id === request.toolCallId, + ); + if (matches.length !== 1 || matches[0]!.status !== "completed") + return { ...base, reason: "completed_patch_not_found" }; + const changes = matches[0]!.changes; + if (!Array.isArray(changes) || changes.length === 0 || changes.length > 64) + return {...base, reason: "unsupported_patch_size"}; + // Validate every native change before constructing any patch. Generated types cannot + // validate runtime JSON from an older/newer native process. + for (const change of changes as unknown[]) { + if (change === null || typeof change !== "object") return {...base, reason: "unsupported_patch"}; + const value = change as {path?: unknown; kind?: {type?: unknown; move_path?: unknown}; diff?: unknown}; + if (typeof value.path !== "string" || !value.path || typeof value.diff !== "string" + || value.kind === null || typeof value.kind !== "object" + || !["add", "delete", "update"].includes(String(value.kind.type)) + || (value.kind.type === "update" && value.kind.move_path != null && typeof value.kind.move_path !== "string")) { + return {...base, reason: "unsupported_patch"}; + } + } + const found = await git(session.cwd, ["rev-parse", "--show-toplevel"]); + if (found.code !== 0) return { ...base, reason: "not_git_repository" }; + const root = await realpath(found.stdout.trim()); + const roots = await Promise.all( + [session.cwd, ...session.additionalDirectories].map((value) => + realpath(value), + ), + ); + let patch = ""; + const paths = new Set(); + try { + for (const change of changes) { + const source = await checkedPath( + root, + roots, + path.resolve(session.cwd, change.path), + ); + paths.add(source.absolute); + let part: string | null; + switch (change.kind.type) { + case "add": + part = createAddedFileGitPatch(source.relative, change.diff); + break; + case "delete": + part = createDeletedFileGitPatch(source.relative, change.diff); + break; + case "update": { + const target = change.kind.move_path + ? await checkedPath( + root, + roots, + path.resolve(session.cwd, change.kind.move_path), + ) + : source; + paths.add(target.absolute); + part = createUpdateGitPatch( + source.relative, + target.relative, + change.diff, + ); + break; + } + default: + return { ...base, reason: "unsupported_patch" }; + } + if (part === null) return { ...base, reason: "unsupported_patch" }; + patch += part; + if (Buffer.byteLength(patch, "utf8") > MAX_BYTES) + return { ...base, reason: "unsupported_patch_size" }; + } + base.paths = [...paths].sort(); + const before = await fingerprintFiles(base.paths); + const previewToken = `sha256:${hash(JSON.stringify([root, session.sessionId, request.toolCallId, patch, before]))}`; + if (!request.dryRun && request.previewToken !== previewToken) + return { ...base, reason: "stale_preview" }; + const check = await git( + root, + ["apply", "--reverse", "--check", "--whitespace=nowarn", "-"], + patch, + ); + if (check.code !== 0) return { ...base, reason: "patch_conflict" }; + if (request.dryRun) return { ...base, canRevert: true, previewToken }; + // Validate nodes again immediately before applying, including parents. + for (const file of base.paths) await checkedPath(root, roots, file); + if ( + JSON.stringify(before) !== + JSON.stringify(await fingerprintFiles(base.paths)) + ) + return { ...base, reason: "stale_preview" }; + const applied = await git( + root, + ["apply", "--reverse", "--whitespace=nowarn", "-"], + patch, + ); + if (applied.code !== 0) + return { ...base, reason: "restore_outcome_unknown" }; + return { ...base, canRevert: true, reverted: true }; + } catch (error) { + // Never include file contents or provider stderr in a failure response. + if ( + error instanceof Error && + [ + "outside_workspace", + "unsafe_path", + "unsupported_file_type", + "unsupported_file_type_or_size", + ].includes(error.message) + ) { + return { ...base, reason: error.message }; + } + throw error; + } +} diff --git a/src/SessionQueue.ts b/src/SessionQueue.ts new file mode 100644 index 000000000..dd0de086f --- /dev/null +++ b/src/SessionQueue.ts @@ -0,0 +1,191 @@ +import {RequestError} from "@agentclientprotocol/sdk"; +import {z} from "zod"; +import type {Turn} from "./app-server/v2/Turn"; + +export const SESSION_QUEUE_METHOD = "_session/queue"; +export const SESSION_QUEUE_ACTIONS = ["list", "add", "update", "delete", "reorder", "start"] as const; +export type SessionQueueAction = typeof SESSION_QUEUE_ACTIONS[number]; + +// A conservative tested floor, NOT a claim about the first native release. +// Source: codex-rs/app-server-protocol/src/protocol/v2/thread.rs:926-1027; +// experimental registrations: protocol/common.rs:630-664. Not generated types. +export const SESSION_QUEUE_MIN_NATIVE_VERSION = "0.160.1"; + +const identifier = z.string().refine(value => value.trim().length > 0 && !/[\u0000-\u001f\u007f]/u.test(value)); +const byteOffset = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER); +const textElement = z.strictObject({ + byteRange: z.strictObject({start: byteOffset, end: byteOffset}), + placeholder: z.string().nullable().optional(), +}); +const detail = z.enum(["auto", "low", "high", "original"]).nullable().optional(); + +// Preserve native UserInput, including snake_case text_elements and omitted +// serde-default fields. No ACP text/image conversion, defaults, or dropped keys. +// Source: protocol/v2/turn.rs:382-474. New variants require an explicit review. +const nativeInput = z.union([ + z.strictObject({type: z.literal("text"), text: z.string(), text_elements: z.array(textElement).optional()}), + z.strictObject({type: z.literal("image"), url: z.string(), detail}), + z.strictObject({type: z.literal("image"), fileId: z.string(), detail}), + z.strictObject({type: z.literal("localImage"), path: z.string(), detail}), + z.strictObject({type: z.literal("audio"), url: z.string()}), + z.strictObject({type: z.literal("localAudio"), path: z.string()}), + z.strictObject({type: z.literal("skill"), name: z.string(), path: z.string()}), + z.strictObject({type: z.literal("mention"), name: z.string(), path: z.string()}), +]); +export type NativeQueueInput = z.infer; + +const session = {sessionId: identifier}; +const requestSchema = z.discriminatedUnion("action", [ + z.strictObject({ + ...session, action: z.literal("list"), cursor: z.string().nullable().optional(), + limit: z.number().int().min(0).max(0xffff_ffff).nullable().optional(), + }), + z.strictObject({ + ...session, action: z.literal("add"), input: z.array(nativeInput), clientUserMessageId: identifier, + }), + z.strictObject({ + ...session, action: z.literal("update"), queuedSubmissionId: identifier, input: z.array(nativeInput), + }), + z.strictObject({...session, action: z.literal("delete"), queuedSubmissionId: identifier}), + z.strictObject({...session, action: z.literal("reorder"), queuedSubmissionIds: z.array(identifier)}), + z.strictObject({...session, action: z.literal("start"), queuedSubmissionId: identifier.nullable().optional()}), +]); +export type SessionQueueRequest = z.infer; + +export function parseSessionQueueRequest(value: unknown): SessionQueueRequest { + const parsed = requestSchema.safeParse(value); + if (!parsed.success) { + // Do not echo prompt content, attachment URLs, or unknown injected fields. + throw RequestError.invalidParams(undefined, "Invalid session queue request"); + } + return parsed.data; +} + +export type NativeQueuedSubmission = { + id: string; + input: NativeQueueInput[]; + clientUserMessageId: string; + [key: string]: unknown; +}; +export type NativeQueueListResponse = {data: NativeQueuedSubmission[]; nextCursor: string | null; [key: string]: unknown}; +export type NativeQueueWriteResponse = {queuedSubmission: NativeQueuedSubmission; [key: string]: unknown}; +export type NativeQueueDeleteResponse = {deleted: boolean; [key: string]: unknown}; +export type NativeQueueReorderResponse = Record; +export type NativeQueueStartResponse = {turn: Turn; [key: string]: unknown}; + +type NativeParams = + Omit, "sessionId" | "action"> & {threadId: string}; + +/** No caller-selectable RPC method or free-form native params escape hatch. */ +export interface SessionQueueNative { + list(params: NativeParams<"list">): Promise; + add(params: NativeParams<"add">): Promise; + update(params: NativeParams<"update">): Promise; + delete(params: NativeParams<"delete">): Promise; + reorder(params: NativeParams<"reorder">): Promise; + /** This starts real generation. The owning server must attach turn handlers first. */ + start(params: NativeParams<"start">): Promise; +} + +export type SessionQueueSupport = { + readonly nativeVersion: string | null; + readonly actions: readonly SessionQueueAction[]; +}; + +function eligibleNativeVersion(version: string | null | undefined): boolean { + // Only an actual normalized stable native version. An npm package version, + // missing version, or unverified prerelease is not support evidence. + const match = version?.match(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/u); + if (!match) return false; + const [major, minor, patch] = match.slice(1).map(Number); + if (![major, minor, patch].every(value => Number.isSafeInteger(value))) return false; + return major! > 0 || minor! > 160 || (minor === 160 && patch! >= 1); +} + +/** + * The integrator supplies per-action POSITIVE evidence from the running native + * process (e.g. its actual experimental schema, or a side-effect-free parameter + * validation probe). A list-only probe proves only list. Never call a valid + * add/update/delete/reorder/start to probe, and never treat an arbitrary -32602, + * auth/store/network error, or package version as proof of method support. + * Cache only for that native process generation; re-probe on restart/override. + */ +export async function probeSessionQueueSupport( + nativeVersion: string | null | undefined, + probeAction: (action: SessionQueueAction) => Promise, +): Promise { + const actions: SessionQueueAction[] = []; + if (eligibleNativeVersion(nativeVersion)) { + for (const action of SESSION_QUEUE_ACTIONS) { + try { + if (await probeAction(action) === true) actions.push(action); + } catch { + // Absence of affirmative evidence leaves this action unadvertised. + } + } + } + return Object.freeze({nativeVersion: nativeVersion ?? null, actions: Object.freeze(actions)}); +} + +export function sessionQueueCapability(support: SessionQueueSupport) { + if (!eligibleNativeVersion(support.nativeVersion)) return undefined; + const actions = SESSION_QUEUE_ACTIONS.filter(action => support.actions.includes(action)); + if (actions.length === 0) return undefined; + return {version: 1 as const, method: SESSION_QUEUE_METHOD, actions}; +} + +export type SessionQueueResponse = + | {status: "unsupported"} + | {status: "ok"; result: NativeQueueListResponse | NativeQueueWriteResponse | + NativeQueueDeleteResponse | NativeQueueReorderResponse | NativeQueueStartResponse}; + +/** + * Must run INSIDE the server's lifecycle reservation for this exact session and + * native process: serialize with prompt, rewind, close, delete, provider change, + * and queue operations until the native promise settles. A timeout/transport + * loss does not imply no mutation; the caller must reconcile, never auto-retry. + * This module has no transport, process launching, locks, or event ownership. + */ +export async function runSessionQueue( + value: unknown, + dependencies: {sessionId: string; support: SessionQueueSupport; native: SessionQueueNative}, +): Promise { + const request = parseSessionQueueRequest(value); + if (request.sessionId !== dependencies.sessionId) { + throw RequestError.invalidParams(undefined, "Queue request does not target the bound session"); + } + const capability = sessionQueueCapability(dependencies.support); + if (!capability?.actions.includes(request.action)) return {status: "unsupported"}; + const threadId = dependencies.sessionId; + const native = dependencies.native; + try { + switch (request.action) { + case "list": + return {status: "ok", result: await native.list({ + threadId, + ...("cursor" in request ? {cursor: request.cursor} : {}), + ...("limit" in request ? {limit: request.limit} : {}), + })}; + case "add": + return {status: "ok", result: await native.add({threadId, input: request.input, clientUserMessageId: request.clientUserMessageId})}; + case "update": + return {status: "ok", result: await native.update({threadId, queuedSubmissionId: request.queuedSubmissionId, input: request.input})}; + case "delete": + return {status: "ok", result: await native.delete({threadId, queuedSubmissionId: request.queuedSubmissionId})}; + case "reorder": + return {status: "ok", result: await native.reorder({threadId, queuedSubmissionIds: request.queuedSubmissionIds})}; + case "start": + return {status: "ok", result: await native.start({ + threadId, + ...("queuedSubmissionId" in request ? {queuedSubmissionId: request.queuedSubmissionId} : {}), + })}; + } + } catch (error) { + // A definitive JSON-RPC method-not-found is safe to report as unsupported. + // No other error is converted to success/unsupported and nothing retries. + if (typeof error === "object" && error !== null && "code" in error && error.code === -32601) { + return {status: "unsupported"}; + } + throw error; + } +} diff --git a/src/SessionRewind.ts b/src/SessionRewind.ts index a905d2a8e..1b7f0532c 100644 --- a/src/SessionRewind.ts +++ b/src/SessionRewind.ts @@ -1,6 +1,7 @@ import {createHash} from "node:crypto"; import {RequestError} from "@agentclientprotocol/sdk"; import type {CodexAppServerClient} from "./CodexAppServerClient"; +import type {Thread, Turn} from "./app-server/v2"; import {userInputVisibleText} from "./UserInputContent"; export const SESSION_REWIND_METHOD = "_session/rewind"; @@ -11,58 +12,111 @@ export type SessionHistoryPoint = { messageFingerprint: string; messageOccurrence: number; }; - export type SessionRewindRequest = { sessionId: string; beforeMessage: SessionHistoryPoint; resumeAtMessage?: SessionHistoryPoint; }; - export type SessionRewindResponse = {rewound: boolean}; +type RewindHooks = { + /** Called only after validation. Stops an active prompt and awaits its cleanup. */ + beforeMutation?: () => Promise; + mutationStarted?: () => void; +}; + export async function rewindSession( request: SessionRewindRequest, client: CodexAppServerClient, + hooks: RewindHooks = {}, ): Promise { - const history = await client.threadReadWithHistory(request.sessionId); - const userTurns = history.thread.turns.flatMap((turn, turnIndex) => turn.items - .flatMap((item, itemIndex) => item.type === "userMessage" - ? [{turn, turnIndex, item, itemIndex}] - : [])); - const candidates = messageIdCandidates(request.beforeMessage.messageId); - const exact = candidates - .map(candidate => userTurns.find(({item}) => item.id === candidate)) - .find(match => match !== undefined); - const match = exact ?? userTurns.filter(({item}) => - fingerprint(userInputVisibleText(item.content)) === request.beforeMessage.messageFingerprint, - )[request.beforeMessage.messageOccurrence - 1]; - if (!match) { - throw RequestError.invalidParams( - {messageId: request.beforeMessage.messageId}, - `Rewind message ${request.beforeMessage.messageId} was not found in session ${request.sessionId}`, - ); + const read = async () => { + const {thread} = await client.threadReadWithHistory(request.sessionId); + if (thread.id !== request.sessionId) throw RequestError.invalidParams(undefined, "Rewind history session changed"); + if (thread.historyMode !== "legacy" && thread.historyMode !== "paginated") { + throw RequestError.invalidParams(undefined, "Unknown history mode; rewind requires an explicit native history mode"); + } + return thread; + }; + let history = await read(); + const target = resolveTarget(request, history); + const prefix = history.turns.slice(0, target.turnIndex); + if (hooks.beforeMutation) { + await hooks.beforeMutation(); + // Cancellation may append the final interrupted items. Re-read the target and retained + // prefix, rather than applying a count computed before prompt settlement. + history = await read(); + const current = resolveTarget(request, history); + if (current.turn.id !== target.turn.id || historyKey(history.turns.slice(0, current.turnIndex)) !== historyKey(prefix)) { + throw RequestError.invalidParams(undefined, "Rewind history changed while settling the prompt; reload history"); + } } - if (match.itemIndex !== 0) { - throw RequestError.invalidParams( - {messageId: request.beforeMessage.messageId}, - `Rewind message ${request.beforeMessage.messageId} does not start a turn`, - ); - } - if (history.thread.historyMode === "legacy") { - await client.threadRollback({ - threadId: request.sessionId, - numTurns: history.thread.turns.length - match.turnIndex, - }); + hooks.mutationStarted?.(); + // This choice is made from native metadata, NEVER from a caught error or a missing response field. + if (history.historyMode === "legacy") { + await client.threadRollback({threadId: request.sessionId, numTurns: history.turns.length - target.turnIndex}); } else { - await client.threadRevert({threadId: request.sessionId, beforeTurnId: match.turn.id}); + await client.threadRevert({threadId: request.sessionId, beforeTurnId: target.turn.id}); + } + // thread/revert returns turns:[] even for a nonempty prefix. Read persisted native history. + const after = await read(); + if (historyKey(after.turns) !== historyKey(prefix)) { + throw RequestError.internalError(undefined, "Native rewind did not retain the expected prefix; reload history before continuing"); } return {rewound: true}; } +function resolveTarget(request: SessionRewindRequest, history: Thread) { + const users = history.turns.flatMap((turn, turnIndex) => turn.items.flatMap((item, itemIndex) => + item.type === "userMessage" ? [{turn, turnIndex, item, itemIndex}] : [])); + const point = request.beforeMessage; + validatePoint(point); + const exact = messageIdCandidates(point.messageId).map(id => users.find(({item}) => item.id === id)).find(Boolean); + const matches = () => users.filter(({item}) => fingerprint(userInputVisibleText(item.content)) === point.messageFingerprint); + const candidates = exact ? undefined : matches(); + const match = exact ?? candidates?.[point.messageOccurrence - 1]; + if (!match) throw RequestError.invalidParams({messageId: point.messageId}, `Rewind message ${point.messageId} was not found in session ${request.sessionId}`); + if (fingerprint(userInputVisibleText(match.item.content)) !== point.messageFingerprint) { + throw RequestError.invalidParams(undefined, "Rewind message fingerprint changed; reload history"); + } + if (match.itemIndex !== 0) throw RequestError.invalidParams(undefined, `Rewind message ${point.messageId} does not start a turn`); + // Text-only fallback cannot identify content omitted by ACP replay (e.g. audio/resource links). + if (!exact && (userInputVisibleText(match.item.content).length === 0 || match.item.content.some(input => + input.type === "audio" || input.type === "localAudio" || input.type === "mention" || input.type === "localImage" + || (input.type === "text" && input.text.startsWith("# Files "))))) { + throw RequestError.invalidParams(undefined, "Rewind attachment identity requires a current message ID; reload history"); + } + const retained = history.turns.slice(0, match.turnIndex); + const assistants = retained.flatMap(turn => turn.items.flatMap(item => item.type === "agentMessage" ? [item] : [])); + if (request.resumeAtMessage) { + const anchor = request.resumeAtMessage; + validatePoint(anchor); + const last = assistants.at(-1); + if (!last || fingerprint(last.text) !== anchor.messageFingerprint + || !messageIdCandidates(anchor.messageId).includes(last.id)) { + throw RequestError.invalidParams(undefined, "Rewind retained boundary changed; reload history"); + } + } else if (!exact && (candidates?.length ?? 0) > 1) { + throw RequestError.invalidParams(undefined, "Repeated rewind fingerprint requires a current retained boundary; reload history"); + } + return match; +} + +function validatePoint(point: SessionHistoryPoint): void { + if (!point || typeof point.messageId !== "string" || !point.messageId.trim() + || !/^sha256:[0-9a-f]{64}$/.test(point.messageFingerprint) + || !Number.isSafeInteger(point.messageOccurrence) || point.messageOccurrence < 1) { + throw RequestError.invalidParams(undefined, "Invalid rewind history point"); + } +} + +function historyKey(turns: Turn[]): string { + // Runtime status/timing can change during shutdown; the retained IDs and items must not. + return JSON.stringify(turns.map(turn => [turn.id, turn.items])); +} function fingerprint(text: string): string { return `sha256:${createHash("sha256").update(text, "utf8").digest("hex")}`; } - function messageIdCandidates(messageId: string): string[] { const protocolMessageId = messageId.replace(/:segment:\d+$/, ""); return protocolMessageId === messageId ? [messageId] : [messageId, protocolMessageId]; diff --git a/src/SessionRuntime.ts b/src/SessionRuntime.ts new file mode 100644 index 000000000..7b0c692f9 --- /dev/null +++ b/src/SessionRuntime.ts @@ -0,0 +1,119 @@ +import {RequestError} from "@agentclientprotocol/sdk"; +import {z} from "zod"; +import type {CodexAcpClient} from "./CodexAcpClient"; +import type {SessionState} from "./CodexAcpServer"; +import {listMcpServerStatus} from "./mcp/McpServerStatusList"; + +export const RUNTIME_READ_METHOD = "_session/runtime/read"; +export const RUNTIME_CONTROL_METHOD = "_session/runtime/control"; +export const runtimeReadParser = z.object({ + sessionId: z.string().trim().min(1), + resource: z.enum(["context", "usage", "mcp", "commands", "plugins"]), +}).strict(); +export const runtimeControlParser = z.object({ + sessionId: z.string().trim().min(1), + action: z.enum(["reloadSkills", "reconnectMcp", "reloadPlugins"]), +}).strict(); +export type RuntimeReadRequest = z.infer; +export type RuntimeControlRequest = z.infer; +export type RuntimeResponse = + | {version: 1, status: "ok", data: unknown} + | {version: 1, status: "unavailable", reason: "timeout" | "cancelled" | "stale"}; + +export function runtimeCapability() { + return { + version: 1, + readMethod: RUNTIME_READ_METHOD, + controlMethod: RUNTIME_CONTROL_METHOD, + reads: ["context", "usage", "mcp", "commands", "plugins"], + controls: ["reloadSkills", "reconnectMcp", "reloadPlugins"], + }; +} + +export type RuntimeSession = Pick; +type RuntimeClient = Pick; + +/** Do not synthesize prompt categories from aggregate token usage. Null means + * the native runtime has not supplied the measurement, not zero tokens. */ +export async function readRuntime( + client: RuntimeClient, + session: RuntimeSession, + request: RuntimeReadRequest, + signal: AbortSignal, + isCurrent: () => boolean = () => true, + timeoutMs = 5000, +): Promise { + if (request.sessionId !== session.sessionId) throw RequestError.invalidParams(undefined, "Session mismatch"); + if (signal.aborted) return {version: 1, status: "unavailable", reason: "cancelled"}; + if (!isCurrent()) return {version: 1, status: "unavailable", reason: "stale"}; + const operation = async (): Promise => { + switch (request.resource) { + case "context": return { + model: session.currentModelId, + totalTokens: session.lastTokenUsage?.totalTokens ?? null, + maxTokens: session.modelContextWindow, + categories: null, + source: "last_native_usage_event", + }; + case "usage": return {last: session.lastTokenUsage, total: session.totalTokenUsage, rateLimits: session.rateLimits}; + case "plugins": return await client.installedPlugins([session.cwd, ...session.additionalDirectories]); + case "commands": return await client.listSkills({cwds: [session.cwd, ...session.additionalDirectories]}); + case "mcp": { + // The existing paginated reader takes only listMcpServers in practice. + const servers = await listMcpServerStatus(client as CodexAcpClient, session.sessionId); + return servers.map(server => ({ + name: server.name, + status: server.runtimeStatus, + authStatus: server.authStatus, + pluginId: server.pluginId, + serverInfo: server.serverInfo, + toolNames: Object.keys(server.tools), + })); + } + } + }; + let timer: ReturnType | undefined; + let onAbort: (() => void) | undefined; + try { + const result = await Promise.race([ + operation().then(data => ({version: 1, status: "ok", data})), + new Promise(resolve => { + timer = setTimeout(() => resolve({version: 1, status: "unavailable", reason: "timeout"}), timeoutMs); + timer.unref?.(); + }), + new Promise(resolve => { + onAbort = () => resolve({version: 1, status: "unavailable", reason: "cancelled"}); + signal.addEventListener("abort", onAbort, {once: true}); + }), + ]); + return isCurrent() ? result : {version: 1, status: "unavailable", reason: "stale"}; + } finally { + if (timer) clearTimeout(timer); + if (onAbort) signal.removeEventListener("abort", onAbort); + } +} + +/** The server must retain its lifecycle lease until native completion. + * reloadMcpServers is process-wide: use the provider barrier, not just a + * per-session guard, and do not claim the connections are ready from its ack. */ +export async function controlRuntime( + client: RuntimeClient, + session: RuntimeSession, + request: RuntimeControlRequest, +): Promise { + if (request.sessionId !== session.sessionId) throw RequestError.invalidParams(undefined, "Session mismatch"); + switch (request.action) { + case "reloadSkills": return { + version: 1, status: "ok", + data: await client.listSkills({cwds: [session.cwd, ...session.additionalDirectories], forceReload: true}), + }; + case "reloadPlugins": + return {version: 1, status: "ok", data: {...await client.reconcilePlugins(), scope: "provider", runtimeReady: false}}; + case "reconnectMcp": + await client.reloadMcpServers(); + return {version: 1, status: "ok", data: {completed: true, scope: "provider", connectionsReady: false}}; + } +} diff --git a/src/UserInputContent.ts b/src/UserInputContent.ts index e7502d655..37cc65d61 100644 --- a/src/UserInputContent.ts +++ b/src/UserInputContent.ts @@ -1,32 +1,36 @@ -import * as acp from "@agentclientprotocol/sdk"; +import type * as acp from "@agentclientprotocol/sdk"; import type {UserInput} from "./app-server/v2"; +import {attachmentFileUri, desktopAttachmentHistory} from "./DesktopAttachmentHistory"; export function userInputToContentBlocks(input: UserInput): acp.ContentBlock[] { switch (input.type) { case "text": - return input.text.length > 0 ? [{type: "text", text: input.text}] : []; + return desktopAttachmentHistory(input.text) + ?? (input.text.length > 0 ? [{ type: "text", text: input.text }] : []); case "image": - return [{type: "text", text: formatUriAsLink("image", input.url)}]; - case "localImage": { - const uri = input.path.startsWith("file://") ? input.path : `file://${input.path}`; - return [{type: "text", text: formatUriAsLink(null, uri)}]; + return [{ + type: "text", + text: "url" in input + ? formatUriAsLink("image", input.url) + : `image:${input.fileId}`, + }]; + case "localImage": + case "localAudio": + case "mention": { + const uri = attachmentFileUri(input.path); + const fileName = input.path.split(/[\\/]/).pop() || input.type; + const name = input.type === "mention" && input.name.trim().length > 0 ? input.name : fileName; + return uri !== null + ? [{type: "resource_link", name, uri}] + : [{type: "text", text: formatUriAsLink(name, input.path)}]; } case "skill": - return [{type: "text", text: `skill:${input.name} (${input.path})`}]; + return [{ type: "text", text: `skill:${input.name} (${input.path})` }]; case "audio": - case "localAudio": - case "mention": - return []; + return [{type: "text", text: formatUriAsLink("audio", input.url)}]; } } -export function userInputVisibleText(content: UserInput[]): string { - return content.flatMap(userInputToContentBlocks) - .filter((block): block is Extract => block.type === "text") - .map(block => block.text) - .join(""); -} - function formatUriAsLink(name: string | null, uri: string): string { if (name && name.length > 0) { return `[@${name}](${uri})`; @@ -38,3 +42,9 @@ function formatUriAsLink(name: string | null, uri: string): string { } return uri; } + +export function userInputVisibleText(content: UserInput[]): string { + return content.flatMap(userInputToContentBlocks) + .filter((block): block is Extract => block.type === "text") + .map(block => block.text).join(""); +} diff --git a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts index 7fd3ab720..7ae250778 100644 --- a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts +++ b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts @@ -1,3 +1,4 @@ +import path from "node:path"; // noinspection ES6RedundantAwait import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; @@ -498,7 +499,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { }); expect(extraRootsSetSpy).toHaveBeenCalledWith({ - extraRoots: ["/skills/one/.agents/skills", "/skills/two/.agents/skills"], + extraRoots: [path.join("/skills/one", ".agents", "skills"), path.join("/skills/two", ".agents", "skills")], }); expect(extraRootsSetSpy.mock.invocationCallOrder[0]!).toBeLessThan(threadStartSpy.mock.invocationCallOrder[0]!); expect(listSkillsSpy).not.toHaveBeenCalled(); @@ -533,7 +534,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { expect(session.additionalDirectories).toEqual(["/workspace/extra"]); expect(extraRootsSetSpy).toHaveBeenCalledWith({ - extraRoots: ["/workspace/extra/.agents/skills"], + extraRoots: [path.join("/workspace/extra", ".agents", "skills")], }); expect(extraRootsSetSpy.mock.invocationCallOrder[0]!).toBeLessThan(threadStartSpy.mock.invocationCallOrder[0]!); expect(listSkillsSpy).not.toHaveBeenCalled(); @@ -1384,7 +1385,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { }); expect(extraRootsSetSpy).toHaveBeenCalledWith({ - extraRoots: ["/workspace/extra/.agents/skills"], + extraRoots: [path.join("/workspace/extra", ".agents", "skills")], }); expect(listSkillsSpy).not.toHaveBeenCalledWith(expect.objectContaining({forceReload: true})); expect(turnStartSpy.mock.calls[0]![0].sandboxPolicy).toMatchObject({ diff --git a/src/__tests__/CodexACPAgent/initialize.test.ts b/src/__tests__/CodexACPAgent/initialize.test.ts index c35374401..7574fb024 100644 --- a/src/__tests__/CodexACPAgent/initialize.test.ts +++ b/src/__tests__/CodexACPAgent/initialize.test.ts @@ -1,3 +1,7 @@ +import {fileRevertCapability} from "../../SessionFileRevert"; +import {sessionDiscoveryCapability} from "../../SessionDiscovery"; +import {archiveCapability} from "../../SessionArchive"; +import {runtimeCapability} from "../../SessionRuntime"; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { CodexAcpServer } from '../../CodexAcpServer'; import * as acp from '@agentclientprotocol/sdk'; @@ -67,8 +71,13 @@ describe('CodexACPAgent - initialize', () => { }, authMethods: getCodexAuthMethods(), _meta: { + runtime: runtimeCapability(), + archive: archiveCapability(), + fileRevert: fileRevertCapability(), + discovery: sessionDiscoveryCapability(), steering: { supported: true, + idleBehavior: ["promptRequired"], }, }, }); @@ -80,8 +89,13 @@ describe('CodexACPAgent - initialize', () => { clientCapabilities: {_meta: {jetbrains: {air: {version: 1, capabilities: []}}}}, }); expect(result._meta).toEqual({ + runtime: runtimeCapability(), + archive: archiveCapability(), + fileRevert: fileRevertCapability(), + discovery: sessionDiscoveryCapability(), steering: { supported: true, + idleBehavior: ["promptRequired"], }, jetbrains: { air: { @@ -91,7 +105,7 @@ describe('CodexACPAgent - initialize', () => { controlMethod: "_session/goal", actions: ["set", "pause", "resume", "clear"], }, - capabilities: ["sessionFailure", "diffPatch", "agentFileChangeReport", "nativeSubagentSessions", "asyncTasks", "recommendedValue", "rawInputRendering", "planContentDelta", "codexHooks"], + capabilities: ["sessionFailure", "diffPatch", "agentFileChangeReport", "nativeSubagentSessions", "asyncTasks", "recommendedValue", "rawInputRendering", "planContentDelta", "codexHooks", "sessionRewind"], }, }, }); diff --git a/src/__tests__/CodexACPAgent/session-rewind-lifecycle.test.ts b/src/__tests__/CodexACPAgent/session-rewind-lifecycle.test.ts new file mode 100644 index 000000000..a707c6709 --- /dev/null +++ b/src/__tests__/CodexACPAgent/session-rewind-lifecycle.test.ts @@ -0,0 +1,217 @@ +import {createHash} from "node:crypto"; +import {describe, expect, it, vi} from "vitest"; +import {createCodexMockTestFixture, createTestSessionState, mockPromptTurn} from "../acp-test-utils"; +import {SESSION_REWIND_METHOD} from "../../SessionRewind"; +import {SESSION_STEERING_METHOD} from "../../AcpExtensions"; +import {OPENAI_PROVIDER_ID} from "../../CodexAcpClient"; +import type {CodexAcpServer, SessionState} from "../../CodexAcpServer"; + +const sessionId = "session-id"; +const request = {sessionId, beforeMessage:{messageId:"user",messageFingerprint:`sha256:${createHash("sha256").update("original").digest("hex")}`,messageOccurrence:1}}; +const steer = {sessionId,prompt:[{type:"text",text:"followup"}],_meta:{steering:{idleBehavior:"promptRequired"}}}; +function deferred() {let resolve!: (value:T)=>void; const promise=new Promise(r=>{resolve=r;});return {promise,resolve};} +function setup() { + const fixture=createCodexMockTestFixture(); + const agent=fixture.getCodexAcpAgent(); + const state=createTestSessionState(); + (agent as unknown as {sessions:Map}).sessions.set(sessionId,state); + return {fixture,agent,state,client:fixture.getCodexAcpClient(),native:fixture.getCodexAppServerClient()}; +} +function rewind(agent:CodexAcpServer) {return agent.extMethod(SESSION_REWIND_METHOD,request);} + +describe("rewind lifecycle",()=>{ + it("rejects competing operations while rewinding and releases the fence on validation failure",async()=>{ + const {agent,client}=setup(); const gate=deferred<{rewound:boolean}>(); + const call=vi.spyOn(client,"rewindSession").mockReturnValue(gate.promise); + const pending=rewind(agent); + await vi.waitFor(()=>expect(call).toHaveBeenCalledOnce()); + for(const attempt of [ + ()=>rewind(agent),()=>agent.closeSession({sessionId}), + ()=>agent.resumeSession({sessionId,cwd:"/test/cwd",mcpServers:[]}), + ()=>agent.loadSession({sessionId,cwd:"/test/cwd",mcpServers:[]}), + ()=>agent.prompt({sessionId,prompt:[]}), + ()=>agent.extMethod(SESSION_STEERING_METHOD,steer), + ()=>agent.setSessionMode({sessionId,modeId:"read-only"}), + ()=>agent.setSessionConfigOption({sessionId,configId:"model",value:"m"}), + ()=>agent.readSessionRuntime({sessionId,resource:"context"}), + ]) await expect(attempt()).rejects.toThrow(); + gate.resolve({rewound:true});await pending; + call.mockRejectedValueOnce(new Error("invalid target")); + await expect(rewind(agent)).rejects.toThrow("invalid target"); + call.mockResolvedValue({rewound:true}); + await expect(rewind(agent)).resolves.toEqual({rewound:true}); + }); + it("does not let rewind overtake an earlier settings change",async()=>{ + const {agent,client}=setup();const gate=deferred(); + const settings=vi.spyOn(client,"setCollaborationMode").mockReturnValue(gate.promise); + const pending=agent.setSessionConfigOption({sessionId,configId:"collaboration_mode",value:"plan"}); + await vi.waitFor(()=>expect(settings).toHaveBeenCalledOnce()); + const call=vi.spyOn(client,"rewindSession"); + await expect(rewind(agent)).rejects.toMatchObject({data:expect.stringContaining("update is in progress")}); + expect(call).not.toHaveBeenCalled();gate.resolve();await pending; + }); + it("blocks a later provider replacement until rewind has finished",async()=>{ + const replacement=createCodexMockTestFixture().getCodexAcpClient(); + vi.spyOn(replacement,"initialize").mockResolvedValue(); + vi.spyOn(replacement,"resumeSession").mockResolvedValue({} as never); + const restart=vi.fn().mockResolvedValue(replacement); + const fixture=createCodexMockTestFixture(restart);const agent=fixture.getCodexAcpAgent(); + await agent.initialize({protocolVersion:1}); + (agent as unknown as {sessions:Map}).sessions.set(sessionId,createTestSessionState()); + const gate=deferred<{rewound:boolean}>(); + const call=vi.spyOn(fixture.getCodexAcpClient(),"rewindSession").mockReturnValue(gate.promise); + const pending=rewind(agent);await vi.waitFor(()=>expect(call).toHaveBeenCalledOnce()); + const update=agent.setProvider({providerId:OPENAI_PROVIDER_ID,apiType:"openai",baseUrl:"https://example.test/v1"}); + await Promise.resolve();expect(restart).not.toHaveBeenCalled(); + gate.resolve({rewound:true});await pending;await update;expect(restart).toHaveBeenCalledOnce(); + }); + it("requires a successful load after a dispatched rewind has an unknown outcome",async()=>{ + const {agent,client,fixture}=setup(); + const call=vi.spyOn(client,"rewindSession").mockImplementation(async(_request,hooks)=>{hooks?.mutationStarted?.();throw Error("reload failed");}); + await expect(rewind(agent)).rejects.toThrow("reload failed"); + await expect(agent.prompt({sessionId,prompt:[]})).rejects.toMatchObject({data:expect.stringContaining("outcome is uncertain")}); + await expect(agent.extMethod(SESSION_STEERING_METHOD,steer)).rejects.toMatchObject({data:expect.stringContaining("outcome is uncertain")}); + await expect(rewind(agent)).rejects.toMatchObject({data:expect.stringContaining("outcome is uncertain")}); + vi.spyOn(agent as unknown as {loadSessionDuringOperation:()=>Promise<{}>},"loadSessionDuringOperation").mockRejectedValueOnce(Error("read failed")).mockResolvedValue({}); + await expect(agent.loadSession({sessionId,cwd:"/test/cwd",mcpServers:[]})).rejects.toThrow("read failed"); + await expect(rewind(agent)).rejects.toMatchObject({data:expect.stringContaining("outcome is uncertain")}); + await agent.loadSession({sessionId,cwd:"/test/cwd",mcpServers:[]}); + call.mockResolvedValue({rewound:true});await rewind(agent); + mockPromptTurn(fixture,sessionId); + await expect(agent.prompt({sessionId,prompt:[{type:"text",text:"continue"}]})).resolves.toMatchObject({stopReason:"end_turn"}); + }); + it("rejects rewind in the turn-start registration gap without releasing an unknown writer",async()=>{ + const {agent,fixture,native,client}=setup();const started=deferred(); + const turnStart=vi.spyOn(native,"turnStart").mockReturnValue(started.promise); + const pending=agent.prompt({sessionId,prompt:[{type:"text",text:"starting"}]}); + await vi.waitFor(()=>expect(turnStart).toHaveBeenCalledOnce()); + const call=vi.spyOn(client,"rewindSession"); + await expect(rewind(agent)).rejects.toMatchObject({data:expect.stringContaining("startup is pending")});expect(call).not.toHaveBeenCalled(); + await agent.closeSession({sessionId});await expect(pending).resolves.toMatchObject({stopReason:"cancelled"}); + started.resolve({turn:{id:"late",items:[],itemsView:"notLoaded",status:"interrupted",error:null,startedAt:null,completedAt:null,durationMs:null}} as never); + await vi.waitFor(()=>expect(fixture.getCodexConnectionEvents([]).some(e=>e.eventType==="request"&&e.method==="turn/interrupt")).toBe(true)); + }); +}); + +describe("opt-in steering",()=>{ + it("returns promptRequired while idle without calling turn/start",async()=>{ + const {agent,native}=setup();const start=vi.spyOn(native,"turnStart"); + await expect(agent.extMethod(SESSION_STEERING_METHOD,steer)).resolves.toEqual({outcome:"promptRequired"}); + expect(start).not.toHaveBeenCalled(); + }); + it("returns promptRequired if the native turn ends before steer is accepted",async()=>{ + const {agent,native,state}=setup();state.currentTurnId="active"; + const gate=deferred();const call=vi.spyOn(native,"turnSteer").mockReturnValue(gate.promise); + const pending=agent.extMethod(SESSION_STEERING_METHOD,steer); + await vi.waitFor(()=>expect(call).toHaveBeenCalledOnce()); + state.currentTurnId=null;gate.resolve(Promise.reject(Error("no active turn to steer")) as never); + await expect(pending).resolves.toEqual({outcome:"promptRequired"}); + }); + it("blocks rewind while a steer is already in flight",async()=>{ + const {agent,native,client,state}=setup();state.currentTurnId="active"; + const gate=deferred();const call=vi.spyOn(native,"turnSteer").mockReturnValue(gate.promise); + const pending=agent.extMethod(SESSION_STEERING_METHOD,steer); + await vi.waitFor(()=>expect(call).toHaveBeenCalledOnce()); + const revert=vi.spyOn(client,"rewindSession");await expect(rewind(agent)).rejects.toMatchObject({data:expect.stringContaining("update is in progress")}); + expect(revert).not.toHaveBeenCalled();gate.resolve({} as never);await expect(pending).resolves.toEqual({outcome:"injected"}); + }); +}); + +describe("runtime lifecycle",()=>{ + it("requires provider idle for MCP reconnect, and fences all sessions while reconnecting",async()=>{ + const {agent,client}=setup();const gate=deferred();const reload=vi.spyOn(client,"reloadMcpServers").mockReturnValue(gate.promise); + const pending=agent.controlSessionRuntime({sessionId,action:"reconnectMcp"});await vi.waitFor(()=>expect(reload).toHaveBeenCalledOnce()); + await expect(agent.prompt({sessionId,prompt:[]})).rejects.toMatchObject({data:expect.stringContaining("Runtime control")}); + await expect(rewind(agent)).rejects.toMatchObject({data:expect.stringContaining("Runtime control")}); + await expect(agent.closeSession({sessionId})).rejects.toMatchObject({data:expect.stringContaining("Runtime control")}); + await expect(agent.setProvider({providerId:OPENAI_PROVIDER_ID,apiType:"openai",baseUrl:"https://example.test"})).rejects.toMatchObject({data:expect.stringContaining("Runtime control")}); + gate.resolve();await expect(pending).resolves.toMatchObject({status:"ok",data:{scope:"provider",connectionsReady:false}}); + }); + it("rejects reconnect while a prompt is active",async()=>{ + const {agent,fixture,client,native}=setup();mockPromptTurn(fixture,sessionId);const finish=deferred(); + vi.spyOn(native,"awaitTurnCompleted").mockReturnValue(finish.promise); + const pending=agent.prompt({sessionId,prompt:[{type:"text",text:"running"}]}); + await vi.waitFor(()=>expect(agent.getSessionState(sessionId).currentTurnId).toBe("turn-id")); + const reload=vi.spyOn(client,"reloadMcpServers");await expect(agent.controlSessionRuntime({sessionId,action:"reconnectMcp"})).rejects.toMatchObject({data:expect.stringContaining("Provider is busy")});expect(reload).not.toHaveBeenCalled(); + await agent.closeSession({sessionId});await pending; + }); + it("republishes available commands after reloadSkills and releases the control fence",async()=>{ + const {agent,client}=setup();const skills=vi.spyOn(client,"listSkills").mockResolvedValue({data:[]}); + const publish=vi.spyOn(agent as unknown as {publishAvailableCommands:()=>Promise},"publishAvailableCommands").mockResolvedValue(); + await agent.controlSessionRuntime({sessionId,action:"reloadSkills"});expect(skills).toHaveBeenCalledWith({cwds:["/test/cwd"],forceReload:true});expect(publish).toHaveBeenCalledOnce(); + await expect(agent.extMethod(SESSION_STEERING_METHOD,steer)).resolves.toEqual({outcome:"promptRequired"}); + }); +}); + + +describe("native pending queue admission", () => { + it.each(["load", "resume"] as const)("does not reopen after close during %s queue preflight", async method => { + const {agent,client,native} = setup(); + client.queueSupport = {nativeVersion:"0.160.1",actions:["list"]}; + const pending = deferred<{data:[],nextCursor:null}>(); + const list = vi.fn().mockReturnValue(pending.promise); + vi.spyOn(native,"queueNative").mockReturnValue({list} as never); + const resume = vi.spyOn(client,"resumeSession"); + const load = vi.spyOn(client,"loadSession"); + const opening = method === "load" ? agent.loadSession({sessionId,cwd:"/test/cwd",mcpServers:[]}) : agent.resumeSession({sessionId,cwd:"/test/cwd",mcpServers:[]}); + const rejection = expect(opening).rejects.toMatchObject({data:expect.stringContaining("changed while checking")}); + await vi.waitFor(()=>expect(list).toHaveBeenCalledOnce()); + await agent.closeSession({sessionId}); + pending.resolve({data:[],nextCursor:null}); + await rejection; + expect(resume).not.toHaveBeenCalled();expect(load).not.toHaveBeenCalled(); + }); + it("does not read or resume a session while close already holds its fence", async () => { + const {agent,client,native} = setup(); + client.queueSupport={nativeVersion:"0.160.1",actions:["list"]}; + const release=deferred();vi.spyOn(client,"closeSession").mockReturnValue(release.promise); + const closing=agent.closeSession({sessionId}); + const list=vi.fn();vi.spyOn(native,"queueNative").mockReturnValue({list} as never); + await expect(agent.resumeSession({sessionId,cwd:"/test/cwd",mcpServers:[]})).rejects.toMatchObject({data:expect.stringContaining("closing")}); + expect(list).not.toHaveBeenCalled();release.resolve();await closing; + }); + it("rejects persisted pending work before native resume can auto-dispatch", async () => { + const {agent,client,native}=setup();client.queueSupport={nativeVersion:"0.160.1",actions:["list"]}; + vi.spyOn(native,"queueNative").mockReturnValue({list:vi.fn().mockResolvedValue({data:[{id:"queued"}],nextCursor:null})} as never); + const resume=vi.spyOn(client,"resumeSession"); + await expect(agent.resumeSession({sessionId,cwd:"/test/cwd",mcpServers:[]})).rejects.toMatchObject({data:expect.stringContaining("pending queue")}); + expect(resume).not.toHaveBeenCalled(); + }); +}); + + +describe("provider control recovery", () => { + it("invalidates sessions after a hung control, keeps close reachable and rejects late installation", async () => { + vi.useFakeTimers(); + try { + const {agent,client}=setup(); + const native = deferred(); + vi.spyOn(client,"reloadMcpServers").mockReturnValue(native.promise); + const pending=agent.controlSessionRuntime({sessionId,action:"reconnectMcp"}); + const failure=expect(pending).rejects.toThrow("timed out"); + await vi.advanceTimersByTimeAsync(30_000); + await failure; + await expect(agent.closeSession({sessionId})).resolves.toEqual({}); + await expect(agent.newSession({cwd:"/test/cwd",mcpServers:[]})).rejects.toMatchObject({data:expect.stringContaining("invalidated")}); + await expect(agent.setProvider({providerId:OPENAI_PROVIDER_ID,apiType:"openai",baseUrl:"https://example.test"})).rejects.toMatchObject({data:expect.stringContaining("invalidated")}); + native.resolve();await Promise.resolve(); + expect(()=>agent.getSessionState(sessionId)).toThrow(); + } finally { vi.useRealTimers(); } + }); + it("reserves creation before an asynchronous new session can install", async () => { + const {agent,client,state}=setup(); + const created=deferred(); + const open=vi.spyOn(agent as unknown as {getOrCreateSession:()=>Promise},"getOrCreateSession").mockReturnValue(created.promise); + const pending=agent.newSession({cwd:"/test/cwd",mcpServers:[]}); + await vi.waitFor(()=>expect(open).toHaveBeenCalledOnce()); + const reload=vi.spyOn(client,"reloadMcpServers"); + await expect(agent.controlSessionRuntime({sessionId,action:"reconnectMcp"})).rejects.toMatchObject({data:expect.stringContaining("Provider is busy")}); + await expect(agent.revertFiles({sessionId,toolCallId:"file",dryRun:true})).rejects.toMatchObject({data:expect.stringContaining("Provider is busy")}); + await expect(agent.setProvider({providerId:OPENAI_PROVIDER_ID,apiType:"openai",baseUrl:"https://example.test"})).rejects.toThrow(); + expect(reload).not.toHaveBeenCalled(); + created.resolve([sessionId,{currentModelId:state.currentModelId,availableModels:[]},{currentModeId:"agent",availableModes:[]}] as never); + await pending; + reload.mockResolvedValue(); + await expect(agent.controlSessionRuntime({sessionId,action:"reconnectMcp"})).resolves.toMatchObject({status:"ok"}); + }); +}); diff --git a/src/__tests__/SessionDiscovery.test.ts b/src/__tests__/SessionDiscovery.test.ts new file mode 100644 index 000000000..1d94faae8 --- /dev/null +++ b/src/__tests__/SessionDiscovery.test.ts @@ -0,0 +1,31 @@ +import {describe, expect, it, vi} from "vitest"; +import {searchSessions, sessionAttachments, sessionAttachmentsParser, sessionSearchParser, type SessionDiscoveryClient} from "../SessionDiscovery"; +import type {Thread} from "../app-server/v2"; + +describe("native session discovery", () => { + it("rejects invalid search ranges and arbitrary native parameters", () => { + for (const p of [{searchTerm: " "}, {searchTerm: "x", limit: 0}, {searchTerm: "x", limit: 101}, {searchTerm: "x", method: "thread/delete"}]) { + expect(sessionSearchParser.safeParse(p).success).toBe(false); + } + }); + it("preserves the native cursor and snippet without leaking runtime config", async () => { + const native = {threadSearch: vi.fn(async () => ({data: [{thread: {id: "s", cwd: "/work", name: "name", updatedAt: 42, modelProvider: "private-config"} as Thread, snippet: "match"}], nextCursor: "opaque"}))}; + expect(await searchSessions(native, {searchTerm: "match", archived: true, limit: 20})).toEqual({version: 1, sessions: [{sessionId: "s", cwd: "/work", title: "name", updatedAt: 42, snippet: "match"}], nextCursor: "opaque"}); + expect(native.threadSearch).toHaveBeenCalledWith({searchTerm: "match", archived: true, limit: 20}); + }); + it("bounds attachment metadata and rejects extra destinations", () => { + const base = {sessionId: "s", action: "add", attachmentType: "pull_request", identityKey: "https://example.test/pr/1"}; + expect(sessionAttachmentsParser.safeParse({...base, payload: {title: "PR"}}).success).toBe(true); + expect(sessionAttachmentsParser.safeParse({...base, payload: "x".repeat(256 * 1024)}).success).toBe(false); + expect(sessionAttachmentsParser.safeParse({...base, payload: {}, threadId: "other"}).success).toBe(false); + }); + it("uses the checked session identity for every attachment operation", async () => { + const add = vi.fn(async () => ({outcome: "created", attachment: {id: "a"}})); + const native = {threadAttachmentAdd: add} as unknown as SessionDiscoveryClient; + const request = {sessionId: "s", action: "add" as const, attachmentType: "pull_request", identityKey: "url", payload: {url: "https://example.test/pr/1"}}; + await expect(sessionAttachments(native, "other", request)).rejects.toThrow("Session mismatch"); + expect(add).not.toHaveBeenCalled(); + await sessionAttachments(native, "s", request); + expect(add).toHaveBeenCalledWith({threadId: "s", attachmentType: "pull_request", identityKey: "url", payload: request.payload}); + }); +}); diff --git a/src/__tests__/SessionFileRevert.test.ts b/src/__tests__/SessionFileRevert.test.ts new file mode 100644 index 000000000..50ec84f86 --- /dev/null +++ b/src/__tests__/SessionFileRevert.test.ts @@ -0,0 +1,175 @@ +import { execFileSync } from "node:child_process"; +import { mkdtemp, mkdir, writeFile, readFile, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import type { Thread, ThreadItem } from "../app-server/v2"; +import { revertSessionFiles } from "../SessionFileRevert"; + +const directories: string[] = []; +afterEach(async () => { + await Promise.all( + directories + .splice(0) + .map((dir) => rm(dir, { recursive: true, force: true })), + ); +}); +async function setup(changes?: unknown[]) { + const cwd = await mkdtemp(path.join(os.tmpdir(), "codex-file-revert-")); + directories.push(cwd); + execFileSync("git", ["init", "-q", cwd], { windowsHide: true }); + await writeFile(path.join(cwd, "changed.txt"), "old\n"); + execFileSync("git", ["add", "."], { cwd, windowsHide: true }); + const index = await readFile(path.join(cwd, ".git/index")); + await writeFile(path.join(cwd, "changed.txt"), "new\n"); + await writeFile(path.join(cwd, "user.txt"), "keep user's work\n"); + const item = { + type: "fileChange", + id: "tool", + status: "completed", + changes: changes ?? [ + { + path: path.join(cwd, "changed.txt"), + kind: { type: "update", move_path: null }, + diff: "@@ -1 +1 @@\n-old\n+new\n", + }, + ], + } as ThreadItem; + const readHistory = async () => ({ + thread: { id: "session", turns: [{ id: "turn", items: [item] }] } as Thread, + }); + return { + cwd, + index, + readHistory, + session: { sessionId: "session", cwd, additionalDirectories: [] }, + request: { sessionId: "session", toolCallId: "tool", dryRun: true }, + }; +} +describe("native file patch revert", () => { + it("previews then reverses the Git text patch while preserving the real index and unrelated edits", async () => { + const f = await setup(); + const preview = await revertSessionFiles( + f.request, + f.session, + f.readHistory, + ); + expect(preview.canRevert).toBe(true); + expect(await readFile(path.join(f.cwd, "changed.txt"), "utf8")).toBe( + "new\n", + ); + const applied = await revertSessionFiles( + { ...f.request, dryRun: false, previewToken: preview.previewToken }, + f.session, + f.readHistory, + ); + expect(applied.reverted).toBe(true); + expect( + (await readFile(path.join(f.cwd, "changed.txt"), "utf8")).replaceAll( + "\r\n", + "\n", + ), + ).toBe("old\n"); + expect(await readFile(path.join(f.cwd, "user.txt"), "utf8")).toBe("keep user's work\n"); + expect(await readFile(path.join(f.cwd, ".git/index"))).toEqual(f.index); + }); + it("refuses a changed file after preview and does not overwrite the user's new content", async () => { + const f = await setup(); + const preview = await revertSessionFiles( + f.request, + f.session, + f.readHistory, + ); + await writeFile(path.join(f.cwd, "changed.txt"), "new\nuser addition\n"); + expect( + await revertSessionFiles( + { ...f.request, dryRun: false, previewToken: preview.previewToken }, + f.session, + f.readHistory, + ), + ).toMatchObject({ reverted: false, reason: "stale_preview" }); + expect(await readFile(path.join(f.cwd, "changed.txt"), "utf8")).toBe( + "new\nuser addition\n", + ); + }); + it("rejects paths outside the session and Git metadata", async () => { + for (const file of ["../outside.txt", ".git/config"]) { + const f = await setup([ + { path: file, kind: { type: "add" }, diff: "x\n" }, + ]); + expect( + await revertSessionFiles(f.request, f.session, f.readHistory), + ).toMatchObject({ canRevert: false, reverted: false }); + } + }); + it("does not touch files without an explicit preview token", async () => { + const f = await setup(); + await expect( + revertSessionFiles( + { ...f.request, dryRun: false }, + f.session, + f.readHistory, + ), + ).rejects.toThrow(); + }); + it("undoes recorded create/delete patches in one batch", async () => { + const f = await setup([ + { path: "created.txt", kind: { type: "add" }, diff: "created\n" }, + { path: "deleted.txt", kind: { type: "delete" }, diff: "deleted\n" }, + ]); + await writeFile(path.join(f.cwd, "created.txt"), "created\n"); + const preview = await revertSessionFiles( + f.request, + f.session, + f.readHistory, + ); + expect(preview.canRevert).toBe(true); + expect( + await revertSessionFiles( + { ...f.request, dryRun: false, previewToken: preview.previewToken }, + f.session, + f.readHistory, + ), + ).toMatchObject({ reverted: true }); + await expect(readFile(path.join(f.cwd, "created.txt"))).rejects.toThrow(); + expect( + (await readFile(path.join(f.cwd, "deleted.txt"), "utf8")).replaceAll( + "\r\n", + "\n", + ), + ).toBe("deleted\n"); + }); + it("refuses a patch whose current content conflicts", async () => { + const f = await setup(); + await writeFile(path.join(f.cwd, "changed.txt"), "different\n"); + expect( + await revertSessionFiles(f.request, f.session, f.readHistory), + ).toMatchObject({ reason: "patch_conflict", canRevert: false }); + }); + it("refuses directory nodes as patch targets", async () => { + const f = await setup([ + { path: "folder", kind: { type: "add" }, diff: "x\n" }, + ]); + await mkdir(path.join(f.cwd, "folder")); + expect( + await revertSessionFiles(f.request, f.session, f.readHistory), + ).toMatchObject({ reason: "unsupported_file_type" }); + }); + it.each([{type:"futureKind"}, null, {}])("rejects an entire mixed batch with an unknown kind %j", async kind => { + const f = await setup([ + {path:"changed.txt",kind:{type:"update",move_path:null},diff:"@@ -1 +1 @@\n-old\n+new\n"}, + {path:"user.txt",kind,diff:"user content"}, + ]); + const result = await revertSessionFiles(f.request, f.session, f.readHistory); + expect(result).toMatchObject({canRevert:false,reverted:false,reason:"unsupported_patch"}); + expect(await readFile(path.join(f.cwd,"changed.txt"),"utf8")).toBe("new\n"); + expect(await readFile(path.join(f.cwd,"user.txt"),"utf8")).toBe("keep user's work\n"); + expect(await readFile(path.join(f.cwd,".git/index"))).toEqual(f.index); + }); + it("rejects a mixed batch containing a missing diff", async () => { + const f=await setup([{path:"changed.txt",kind:{type:"update",move_path:null},diff:"@@ -1 +1 @@\n-old\n+new\n"},{path:"user.txt",kind:{type:"add"}}]); + expect(await revertSessionFiles(f.request,f.session,f.readHistory)).toMatchObject({reverted:false,reason:"unsupported_patch"}); + expect(await readFile(path.join(f.cwd,"changed.txt"),"utf8")).toBe("new\n"); + }); + +}); diff --git a/src/__tests__/SessionQueue.test.ts b/src/__tests__/SessionQueue.test.ts new file mode 100644 index 000000000..6e5e5630a --- /dev/null +++ b/src/__tests__/SessionQueue.test.ts @@ -0,0 +1,196 @@ +import {describe, expect, it, vi} from "vitest"; +import type {Turn} from "../app-server/v2/Turn"; +import { + SESSION_QUEUE_ACTIONS, SESSION_QUEUE_METHOD, parseSessionQueueRequest, + probeSessionQueueSupport, runSessionQueue, sessionQueueCapability, + type NativeQueueInput, type SessionQueueNative, type SessionQueueSupport, +} from "../SessionQueue"; + +const input: NativeQueueInput[] = [ + {type: "text", text: "正文", text_elements: [{byteRange: {start: 0, end: 6}, placeholder: null}]}, + {type: "text", text: " keep whitespace "}, + {type: "image", fileId: "file-id", detail: "original"}, + {type: "image", url: "data:image/png;base64,AA==", detail: null}, + {type: "localImage", path: "C:\\a.png"}, + {type: "audio", url: "data:audio/wav;base64,AA=="}, + {type: "localAudio", path: "C:\\a.wav"}, + {type: "skill", name: "skill", path: "C:\\SKILL.md"}, + {type: "mention", name: "file", path: "C:\\file.txt"}, +]; +const support: SessionQueueSupport = {nativeVersion: "0.160.1", actions: SESSION_QUEUE_ACTIONS}; +const queued = {id: "native-q", input, clientUserMessageId: "client-id", futureNativeField: {keep: true}}; +const turn: Turn = {id: "native-turn", items: [], itemsView: "notLoaded", status: "inProgress", error: null, startedAt: null, completedAt: null, durationMs: null}; + +function fixture() { + const native = { + list: vi.fn().mockResolvedValue({data: [queued], nextCursor: "opaque-next"}), + add: vi.fn().mockResolvedValue({queuedSubmission: queued}), + update: vi.fn().mockResolvedValue({queuedSubmission: queued}), + delete: vi.fn().mockResolvedValue({deleted: false}), + reorder: vi.fn().mockResolvedValue({}), + start: vi.fn().mockResolvedValue({turn}), + }; + const dependencies = {sessionId: "session", support, native}; + return {native, dependencies, run: (request: unknown) => runSessionQueue(request, dependencies)}; +} + +describe("session queue capability", () => { + it.each(["0.154.0", "0.159.3", "0.160.0", "0.160.1-preview.1", "codex 0.160.1", "2.1.1-preview", "", null, undefined])( + "does not probe or advertise unknown/old/unverified native %s", async version => { + const probe = vi.fn(async () => true); + const result = await probeSessionQueueSupport(version, probe); + expect(probe).not.toHaveBeenCalled(); + expect(result.actions).toEqual([]); + expect(sessionQueueCapability(result)).toBeUndefined(); + }, + ); + it("advertises only each action positively confirmed on the running native", async () => { + const result = await probeSessionQueueSupport("0.160.1", async action => { + if (action === "start") throw new Error("probe transport failed"); + return action === "list"; + }); + expect(sessionQueueCapability(result)).toEqual({version: 1, method: SESSION_QUEUE_METHOD, actions: ["list"]}); + expect(Object.isFrozen(result.actions)).toBe(true); + }); + it.each(["0.160.1", "0.161.0", "1.0.0"])("still requires positive evidence for %s", async version => { + const result = await probeSessionQueueSupport(version, async () => false); + expect(sessionQueueCapability(result)).toBeUndefined(); + }); + it("rejects an old native support record even if its actions were supplied", async () => { + const f = fixture(); + f.dependencies.support = {nativeVersion: "0.154.0", actions: SESSION_QUEUE_ACTIONS}; + await expect(f.run({sessionId: "session", action: "start"})).resolves.toEqual({status: "unsupported"}); + expect(f.native.start).not.toHaveBeenCalled(); + }); +}); + +describe("session queue native boundary", () => { + it("keeps native multimodal input, omission, IDs, and unknown RESULT fields", async () => { + const f = fixture(); + const result = {queuedSubmission: queued, nativeExtra: {revision: "native-owned"}}; + f.native.add.mockResolvedValue(result); + await expect(f.run({sessionId: "session", action: "add", input, clientUserMessageId: "client-id"})) + .resolves.toEqual({status: "ok", result}); + expect(f.native.add).toHaveBeenCalledExactlyOnceWith({threadId: "session", input, clientUserMessageId: "client-id"}); + expect(f.native.add.mock.calls[0]![0].input[1]).not.toHaveProperty("text_elements"); + expect(result.queuedSubmission.id).toBe("native-q"); + }); + it("preserves list pagination, nulls, and the native result object", async () => { + const f = fixture(); + const result = {data: [queued], nextCursor: "opaque-next", total: 200}; + f.native.list.mockResolvedValue(result); + const response = await f.run({sessionId: "session", action: "list", cursor: "opaque-in", limit: 0}); + expect(response.status === "ok" && response.result).toBe(result); + expect(f.native.list).toHaveBeenLastCalledWith({threadId: "session", cursor: "opaque-in", limit: 0}); + await f.run({sessionId: "session", action: "list", cursor: null, limit: null}); + expect(f.native.list).toHaveBeenLastCalledWith({threadId: "session", cursor: null, limit: null}); + await f.run({sessionId: "session", action: "list"}); + expect(f.native.list).toHaveBeenLastCalledWith({threadId: "session"}); + }); + it("updates only the named native submission without replacing its client ID", async () => { + const f = fixture(); + await expect(f.run({sessionId: "session", action: "update", queuedSubmissionId: "q", input})) + .resolves.toEqual({status: "ok", result: {queuedSubmission: queued}}); + expect(f.native.update).toHaveBeenCalledExactlyOnceWith({threadId: "session", queuedSubmissionId: "q", input}); + }); + it("keeps delete=false and does not invent successful removal", async () => { + const f = fixture(); + await expect(f.run({sessionId: "session", action: "delete", queuedSubmissionId: "gone"})) + .resolves.toEqual({status: "ok", result: {deleted: false}}); + expect(f.native.delete).toHaveBeenCalledExactlyOnceWith({threadId: "session", queuedSubmissionId: "gone"}); + }); + it("delegates native ordering and membership validation without local reordering", async () => { + const f = fixture(); + const ids = ["third", "first"]; + await expect(f.run({sessionId: "session", action: "reorder", queuedSubmissionIds: ids})) + .resolves.toEqual({status: "ok", result: {}}); + expect(f.native.reorder).toHaveBeenCalledExactlyOnceWith({threadId: "session", queuedSubmissionIds: ids}); + expect(f.native.list).not.toHaveBeenCalled(); + }); + it.each([{}, {queuedSubmissionId: null}, {queuedSubmissionId: "q"}])("only fake-starts once with native selector %j", async selector => { + const f = fixture(); + await expect(f.run({sessionId: "session", action: "start", ...selector})) + .resolves.toEqual({status: "ok", result: {turn}}); + expect(f.native.start).toHaveBeenCalledExactlyOnceWith({threadId: "session", ...selector}); + expect(f.native.delete).not.toHaveBeenCalled(); + }); + it.each(SESSION_QUEUE_ACTIONS)("never accesses another session for %s", async action => { + const f = fixture(); + const requests = { + list: {}, add: {input, clientUserMessageId: "id"}, update: {input, queuedSubmissionId: "id"}, + delete: {queuedSubmissionId: "id"}, reorder: {queuedSubmissionIds: []}, start: {}, + }; + await expect(f.run({sessionId: "other", action, ...requests[action]})).rejects.toThrow("bound session"); + for (const callback of Object.values(f.native)) expect(callback).not.toHaveBeenCalled(); + }); + it("rejects unproven start even when list was positively probed", async () => { + const f = fixture(); + f.dependencies.support = await probeSessionQueueSupport("0.160.1", async action => action === "list"); + await expect(f.run({sessionId: "session", action: "start"})).resolves.toEqual({status: "unsupported"}); + expect(f.native.start).not.toHaveBeenCalled(); + }); + it("reports a definitive method-not-found without retry or fallback", async () => { + const f = fixture(); + f.native.start.mockRejectedValue({code: -32601, message: "Method not found"}); + await expect(f.run({sessionId: "session", action: "start"})).resolves.toEqual({status: "unsupported"}); + expect(f.native.start).toHaveBeenCalledTimes(1); + }); + it.each([ + {code: -32602, message: "thread not found"}, + {code: -32603, message: "storage failed"}, + new Error("response lost after native commit"), + ])("propagates failures without replaying an uncertain mutation", async error => { + const f = fixture(); + f.native.add.mockRejectedValue(error); + await expect(f.run({sessionId: "session", action: "add", input, clientUserMessageId: "client-id"})).rejects.toBe(error); + expect(f.native.add).toHaveBeenCalledTimes(1); + expect(f.native.list).not.toHaveBeenCalled(); + }); +}); + +describe("session queue input validation", () => { + it.each([ + null, [], "list", {}, + {sessionId: "session", action: "thread/delete"}, + {sessionId: "session", action: "list", threadId: "other"}, + {sessionId: "session", action: "list", method: "thread/delete"}, + {sessionId: "session", action: "list", params: {threadId: "other"}}, + {sessionId: "session", action: "list", limit: -1}, + {sessionId: "session", action: "list", limit: 1.5}, + {sessionId: "session", action: "list", limit: 0x1_0000_0000}, + {sessionId: "session", action: "list", cursor: 1}, + {sessionId: " ", action: "list"}, + {sessionId: "session\n", action: "list"}, + {sessionId: "session", action: "add", input}, + {sessionId: "session", action: "add", input, clientUserMessageId: "id", revision: 1}, + {sessionId: "session", action: "start", input}, + {sessionId: "session", action: "start", queuedSubmissionId: ""}, + {sessionId: "session", action: "reorder", queuedSubmissionIds: [null]}, + {sessionId: "session", action: "update", input, queuedSubmissionId: "q", clientUserMessageId: "replacement"}, + ])("rejects malformed/extra fields before any native call: %j", async request => { + const f = fixture(); + await expect(f.run(request)).rejects.toThrow("Invalid session queue request"); + for (const callback of Object.values(f.native)) expect(callback).not.toHaveBeenCalled(); + }); + it.each([ + {type: "text", text: 42}, + {type: "text", text: "x", text_elements: [{byteRange: {start: -1, end: 1}}]}, + {type: "text", text: "x", text_elements: [{byteRange: {start: 0, end: 1}, placeholder: 7}]}, + {type: "image", url: "data:x", fileId: "ambiguous"}, + {type: "image", url: "data:x", detail: "unlimited"}, + {type: "image", fileId: "x", threadId: "other"}, + {type: "toolOutput", text: "inject"}, + {type: "localImage", path: 42}, + ])("rejects unknown/malformed native input: %j", block => { + expect(() => parseSessionQueueRequest({sessionId: "session", action: "add", input: [block], clientUserMessageId: "id"})) + .toThrow("Invalid session queue request"); + }); + it("does not echo rejected prompt/URL contents in errors", () => { + try { + parseSessionQueueRequest({sessionId: "session", action: "add", input: [{type: "unknown", secret: "sensitive"}], clientUserMessageId: "id"}); + expect.fail("should reject"); + } catch (error) { + expect(String(error)).not.toContain("sensitive"); + } + }); +}); diff --git a/src/__tests__/SessionRewind.test.ts b/src/__tests__/SessionRewind.test.ts index bbc43e011..1228532da 100644 --- a/src/__tests__/SessionRewind.test.ts +++ b/src/__tests__/SessionRewind.test.ts @@ -1,254 +1,138 @@ +import {createHash} from "node:crypto"; import {describe, expect, it, vi} from "vitest"; -import type {CodexAppServerClient} from "../CodexAppServerClient"; +import {rewindSession, type SessionHistoryPoint} from "../SessionRewind"; import {CodexAcpClient} from "../CodexAcpClient"; -import {rewindSession} from "../SessionRewind"; +import type {CodexAppServerClient} from "../CodexAppServerClient"; +import type {Thread, Turn, ThreadItem, UserInput} from "../app-server/v2"; +import {userInputVisibleText} from "../UserInputContent"; + +const hash = (text: string) => `sha256:${createHash("sha256").update(text).digest("hex")}`; +const point = (id: string, text: string, occurrence = 1): SessionHistoryPoint => ({messageId: id, messageFingerprint: hash(text), messageOccurrence: occurrence}); +function turn(id: string, text: string, items: ThreadItem[] = []): Turn { + return {id, items: [{type: "userMessage", id: `u-${id}`, clientId: null, content: [{type: "text", text, text_elements: []}]}, ...items], itemsView: "full", status: "completed", error: null, startedAt: null, completedAt: null, durationMs: null}; +} +function answer(id: string, text: string): ThreadItem { + return {type: "agentMessage", id, text, phase: null, memoryCitation: null, delivery: null, questions: null}; +} +function fixture(turns: Turn[], mode: "legacy" | "paginated" = "paginated") { + const thread = {id: "session", historyMode: mode, turns} as Thread; + const client = { + threadReadWithHistory: vi.fn(async () => ({thread: structuredClone(thread)})), + threadRevert: vi.fn(async ({beforeTurnId}: {beforeTurnId: string}) => { + thread.turns = thread.turns.slice(0, thread.turns.findIndex(t => t.id === beforeTurnId)); + return {thread: {...thread, turns: []}}; + }), + threadRollback: vi.fn(async ({numTurns}: {numTurns: number}) => { + thread.turns = thread.turns.slice(0, -numTurns); + return {thread}; + }), + }; + const run = (beforeMessage: SessionHistoryPoint, resumeAtMessage?: SessionHistoryPoint, hooks?: Parameters[2]) => + rewindSession({sessionId: "session", beforeMessage, ...(resumeAtMessage ? {resumeAtMessage} : {})}, client as unknown as CodexAppServerClient, hooks); + return {client, thread, run}; +} describe("session rewind", () => { - it("reverts the same Codex thread before the selected user turn", async () => { - const client = { - threadReadWithHistory: vi.fn().mockResolvedValue({ - thread: { - turns: [ - {id: "turn-1", items: [{type: "userMessage", id: "user-1", content: [{type: "text", text: "one"}]}]}, - {id: "turn-2", items: [{type: "userMessage", id: "user-2", content: [{type: "text", text: "two"}]}]}, - ], - }, - }), - threadRevert: vi.fn().mockResolvedValue({}), - } as unknown as CodexAppServerClient; - - const result = await rewindSession({ - sessionId: "thread-1", - beforeMessage: { - messageId: "user-2", - messageFingerprint: "sha256:3fc4ccfe745870e2c0d99f71f30ff0656c8d1ed5d3f3b71b17a64d1c0d9a4f5f", - messageOccurrence: 1, - }, - }, client); - - expect(result).toEqual({rewound: true}); - expect(client.threadRevert).toHaveBeenCalledWith({threadId: "thread-1", beforeTurnId: "turn-2"}); + it.each([0, 1, 2])("keeps the same ID and precise prefix before turn %i", async index => { + const f = fixture([turn("1", "one"), turn("2", "two"), turn("3", "three")]); + await expect(f.run(point(`u-${index + 1}`, ["one", "two", "three"][index]!))).resolves.toEqual({rewound: true}); + expect(f.thread.id).toBe("session"); + expect(f.thread.turns).toHaveLength(index); + expect(f.client.threadRollback).not.toHaveBeenCalled(); }); - - it("resolves a restored message through its fingerprint occurrence", async () => { - const client = { - threadReadWithHistory: vi.fn().mockResolvedValue({ - thread: { - turns: [ - {id: "turn-1", items: [{type: "userMessage", id: "new-1", content: [{type: "text", text: "repeat"}]}]}, - {id: "turn-2", items: [{type: "userMessage", id: "new-2", content: [{type: "text", text: "repeat"}]}]}, - ], - }, - }), - threadRevert: vi.fn().mockResolvedValue({}), - } as unknown as CodexAppServerClient; - - const result = await rewindSession({ - sessionId: "thread-1", - beforeMessage: { - messageId: "stale-id", - messageFingerprint: "sha256:25e2b6b106523880e27763084ffa6a0756335be0d7106022535365b9ad39b4b1", - messageOccurrence: 2, - }, - }, client); - - expect(result).toEqual({rewound: true}); - expect(client.threadRevert).toHaveBeenCalledWith({threadId: "thread-1", beforeTurnId: "turn-2"}); + it("supports repeated edits after the retained prefix is persisted", async () => { + const f = fixture([turn("1", "one"), turn("2", "two")]); + await f.run(point("u-2", "two")); + f.thread.turns.push(turn("3", "replacement")); + await f.run(point("u-3", "replacement")); + expect(f.thread.turns.map(t => t.id)).toEqual(["1"]); }); - - it("prefers the exact segmented message id over its protocol id fallback", async () => { - const client = { - threadReadWithHistory: vi.fn().mockResolvedValue({ - thread: { - turns: [ - {id: "turn-1", items: [{type: "userMessage", id: "user-1", content: [{type: "text", text: "fallback"}]}]}, - {id: "turn-2", items: [{type: "userMessage", id: "user-1:segment:0", content: [{type: "text", text: "exact"}]}]}, - ], - }, - }), - threadRevert: vi.fn().mockResolvedValue({}), - } as unknown as CodexAppServerClient; - - await rewindSession({ - sessionId: "thread-1", - beforeMessage: { - messageId: "user-1:segment:0", - messageFingerprint: `sha256:${"0".repeat(64)}`, - messageOccurrence: 1, - }, - }, client); - - expect(client.threadRevert).toHaveBeenCalledWith({threadId: "thread-1", beforeTurnId: "turn-2"}); + it("rejects stale content even when the exact ID still exists", async () => { + const f = fixture([turn("1", "new")]); + await expect(f.run(point("u-1", "old"))).rejects.toThrow("fingerprint changed"); + expect(f.client.threadRevert).not.toHaveBeenCalled(); }); - - it("does not fingerprint history when an exact message id is found", async () => { - const unreadableItem = Object.defineProperty({type: "userMessage", id: "other"}, "content", { - enumerable: true, - get: () => { - throw new Error("fingerprint fallback should not run"); - }, - }); - const client = { - threadReadWithHistory: vi.fn().mockResolvedValue({ - thread: { - turns: [ - {id: "turn-1", items: [unreadableItem]}, - {id: "turn-2", items: [{type: "userMessage", id: "exact", content: [{type: "text", text: "selected"}]}]}, - ], - }, - }), - threadRevert: vi.fn().mockResolvedValue({}), - } as unknown as CodexAppServerClient; - - await rewindSession({ - sessionId: "thread-1", - beforeMessage: { - messageId: "exact", - messageFingerprint: `sha256:${"0".repeat(64)}`, - messageOccurrence: 1, - }, - }, client); - - expect(client.threadRevert).toHaveBeenCalledWith({threadId: "thread-1", beforeTurnId: "turn-2"}); + it("prefers the exact segmented ID before its protocol ID fallback", async () => { + const a = turn("1", "fallback"), b = turn("2", "exact"); + b.items[0]!.id = "u-1:segment:0"; + const f = fixture([a, b]); + await f.run(point("u-1:segment:0", "exact")); + expect(f.thread.turns.map(t => t.id)).toEqual(["1"]); }); - - it("uses the visible replay text when fingerprinting multimodal and skill inputs", async () => { - const client = { - threadReadWithHistory: vi.fn().mockResolvedValue({ - thread: { - historyMode: "paginated", - turns: [{ - id: "turn-1", - items: [{ - type: "userMessage", - id: "new-id", - content: [ - {type: "text", text: "look"}, - {type: "image", url: "https://example.com/image.png"}, - {type: "skill", name: "review", path: "/tmp/SKILL.md"}, - ], - }], - }], - }, - }), - threadRevert: vi.fn().mockResolvedValue({}), - } as unknown as CodexAppServerClient; - - await rewindSession({ - sessionId: "thread-1", - beforeMessage: { - messageId: "stale-id", - messageFingerprint: "sha256:d0425f232dd6a5d6a18eee0fb305ff976368b93fb5ad3da67a4b41d919f7e2de", - messageOccurrence: 1, - }, - }, client); - - expect(client.threadRevert).toHaveBeenCalledWith({threadId: "thread-1", beforeTurnId: "turn-1"}); + it("resolves a unique restored text fingerprint", async () => { + const f = fixture([turn("1", "one")]); + await f.run(point("stale-id", "one")); + expect(f.thread.turns).toEqual([]); }); - - it("uses turn-count rollback for legacy thread history", async () => { - const client = { - threadReadWithHistory: vi.fn().mockResolvedValue({ - thread: { - historyMode: "legacy", - turns: [ - {id: "turn-1", items: [{type: "userMessage", id: "user-1", content: [{type: "text", text: "one"}]}]}, - {id: "turn-2", items: [{type: "userMessage", id: "user-2", content: [{type: "text", text: "two"}]}]}, - {id: "turn-3", items: [{type: "userMessage", id: "user-3", content: [{type: "text", text: "three"}]}]}, - ], - }, - }), - threadRollback: vi.fn().mockResolvedValue({}), - threadRevert: vi.fn(), - } as unknown as CodexAppServerClient; - - await rewindSession({ - sessionId: "thread-1", - beforeMessage: { - messageId: "user-2", - messageFingerprint: "sha256:3fc4ccfe745870e2c0d99f71f30ff0656c8d1ed5d3f3b71b17a64d1c0d9a4f5f", - messageOccurrence: 1, - }, - }, client); - - expect(client.threadRollback).toHaveBeenCalledWith({threadId: "thread-1", numTurns: 2}); - expect(client.threadRevert).not.toHaveBeenCalled(); + it("requires a current retained boundary for repeated fingerprint fallback", async () => { + const f = fixture([turn("1", "repeat", [answer("a-1", "answer")]), turn("2", "repeat")]); + await expect(f.run(point("stale", "repeat", 2))).rejects.toThrow("retained boundary"); + expect(f.client.threadRevert).not.toHaveBeenCalled(); + await f.run(point("stale", "repeat", 2), point("a-1", "answer")); + expect(f.thread.turns.map(t => t.id)).toEqual(["1"]); }); - - it("rejects rewinding a steer inside an existing turn", async () => { - const client = { - threadReadWithHistory: vi.fn().mockResolvedValue({ - thread: { - historyMode: "paginated", - turns: [{ - id: "turn-1", - items: [ - {type: "userMessage", id: "user-1", content: [{type: "text", text: "first"}]}, - {type: "agentMessage", id: "assistant-1", text: "working"}, - {type: "userMessage", id: "steer-1", content: [{type: "text", text: "steer"}]}, - ], - }], - }, - }), - threadRevert: vi.fn(), - } as unknown as CodexAppServerClient; - - await expect(rewindSession({ - sessionId: "thread-1", - beforeMessage: { - messageId: "steer-1", - messageFingerprint: "sha256:57fce44d7c6df51ad8525da1580a246e9d1142d79d1d1f176b1d29643d61ed44", - messageOccurrence: 1, - }, - resumeAtMessage: { - messageId: "assistant-1", - messageFingerprint: `sha256:${"0".repeat(64)}`, - messageOccurrence: 1, - }, - }, client)).rejects.toThrow("does not start a turn"); - expect(client.threadRevert).not.toHaveBeenCalled(); + it("rejects a stale retained boundary without mutating", async () => { + const f = fixture([turn("1", "one", [answer("a-1", "answer")]), turn("2", "two")]); + await expect(f.run(point("u-2", "two"), point("deleted-anchor", "answer"))).rejects.toThrow("boundary changed"); + expect(f.client.threadRevert).not.toHaveBeenCalled(); }); - - it("does not revert when the selected message is absent", async () => { - const client = { - threadReadWithHistory: vi.fn().mockResolvedValue({thread: {turns: []}}), - threadRevert: vi.fn(), - } as unknown as CodexAppServerClient; - - await expect(rewindSession({ - sessionId: "thread-1", - beforeMessage: { - messageId: "missing", - messageFingerprint: `sha256:${"0".repeat(64)}`, - messageOccurrence: 1, - }, - }, client)).rejects.toThrow("Rewind message missing was not found"); - expect(client.threadRevert).not.toHaveBeenCalled(); + it("rejects midturn steering rather than discarding the containing turn", async () => { + const f = fixture([turn("1", "one", [answer("a-1", "working"), ...turn("steer", "steer").items])]); + await expect(f.run(point("u-steer", "steer"))).rejects.toThrow("does not start a turn"); + expect(f.client.threadRevert).not.toHaveBeenCalled(); }); - - it("drains queued session notifications before acknowledging rewind", async () => { - const appServerClient = { - threadReadWithHistory: vi.fn().mockResolvedValue({ - thread: { - historyMode: "paginated", - turns: [{id: "turn-1", items: [{type: "userMessage", id: "user-1", content: [{type: "text", text: "one"}]}]}], - }, - }), - threadRevert: vi.fn().mockResolvedValue({}), - } as unknown as CodexAppServerClient; - const client = new CodexAcpClient(appServerClient); - const waitForNotifications = vi.spyOn(client, "waitForSessionNotifications").mockResolvedValue(); - - await client.rewindSession({ - sessionId: "thread-1", - beforeMessage: { - messageId: "user-1", - messageFingerprint: `sha256:${"0".repeat(64)}`, - messageOccurrence: 1, - }, - }); - - expect(waitForNotifications).toHaveBeenCalledWith("thread-1"); - expect(appServerClient.threadRevert).toHaveBeenCalledBefore(waitForNotifications); + it("shares current replay text for multimodal and skill inputs", async () => { + const f = fixture([turn("1", "look")]); + const content: UserInput[] = [{type:"text", text:"look", text_elements:[]}, {type:"image", url:"https://example.test/i.png"}, {type:"skill", name:"review", path:"/tmp/skill"}]; + (f.thread.turns[0]!.items[0] as Extract).content = content; + await f.run(point("stale", userInputVisibleText(content))); + expect(f.thread.turns).toEqual([]); + }); + it("requires exact identity when replay text omits a resource", async () => { + const f = fixture([turn("1", "")]); + (f.thread.turns[0]!.items[0] as Extract).content = [{type:"mention", name:"file", path:"/tmp/file"}]; + await expect(f.run(point("stale", ""))).rejects.toThrow("attachment identity"); + await f.run(point("u-1", "")); + expect(f.thread.turns).toEqual([]); + }); + it("uses rollback only for explicit legacy metadata", async () => { + const f = fixture([turn("1", "one"), turn("2", "two"), turn("3", "three")], "legacy"); + await f.run(point("u-2", "two")); + expect(f.client.threadRollback).toHaveBeenCalledWith({threadId: "session", numTurns: 2}); + expect(f.client.threadRevert).not.toHaveBeenCalled(); + }); + it.each([{}, {code:-32601}, new Error("legacy rollback suggested"), new Error("reload failed")])("never falls back based on a revert error shape %j", async error => { + const f = fixture([turn("1", "one")]); + f.client.threadRevert.mockRejectedValue(error); + await expect(f.run(point("u-1", "one"))).rejects.toEqual(error); + expect(f.client.threadRollback).not.toHaveBeenCalled(); + }); + it("rejects unknown native history mode before dispatch", async () => { + const f = fixture([turn("1", "one")]); + (f.thread as {historyMode: unknown}).historyMode = undefined; + await expect(f.run(point("u-1", "one"))).rejects.toThrow("Unknown history mode"); + expect(f.client.threadRevert).not.toHaveBeenCalled(); + }); + it("validates before cancelling and rejects a changed prefix after settlement", async () => { + const f = fixture([turn("1", "one"), turn("2", "two")]); + const stop = vi.fn(async () => {f.thread.turns[0] = turn("replaced", "other");}); + await expect(f.run(point("u-2", "two"), undefined, {beforeMutation:stop})).rejects.toThrow("history changed"); + expect(stop).toHaveBeenCalledOnce(); + expect(f.client.threadRevert).not.toHaveBeenCalled(); + await expect(f.run(point("missing", "absent"), undefined, {beforeMutation:stop})).rejects.toThrow("not found"); + expect(stop).toHaveBeenCalledOnce(); + }); + it("checks the actual retained prefix instead of trusting empty response turns", async () => { + const f = fixture([turn("1", "one"), turn("2", "two")]); + f.client.threadRevert.mockResolvedValue({thread:{...f.thread, turns:[]}}); + await expect(f.run(point("u-2", "two"))).rejects.toThrow("expected prefix"); + }); + it("drains native notifications after persisted-prefix verification", async () => { + const f = fixture([turn("1", "one")]); + const client = new CodexAcpClient(f.client as unknown as CodexAppServerClient); + const drain = vi.spyOn(client, "waitForSessionNotifications").mockResolvedValue(); + await client.rewindSession({sessionId:"session",beforeMessage:point("u-1","one")}); + expect(f.client.threadRevert).toHaveBeenCalledBefore(drain); + expect(drain).toHaveBeenCalledWith("session"); }); }); diff --git a/src/__tests__/SessionRuntime.test.ts b/src/__tests__/SessionRuntime.test.ts new file mode 100644 index 000000000..ddb78c88e --- /dev/null +++ b/src/__tests__/SessionRuntime.test.ts @@ -0,0 +1,51 @@ +import {describe, expect, it, vi} from "vitest"; +import {controlRuntime, readRuntime, runtimeControlParser, runtimeReadParser, type RuntimeSession} from "../SessionRuntime"; + +const session: RuntimeSession = { + sessionId: "s", cwd: "/workspace", additionalDirectories: ["/extra"], currentModelId: "m", + lastTokenUsage: null, totalTokenUsage: null, modelContextWindow: null, rateLimits: null, +}; +const client = () => ({ + listSkills: vi.fn(async () => ({data: []})), + listMcpServers: vi.fn(async () => ({data: [], nextCursor: null})), + installedPlugins: vi.fn(async () => ({marketplaces: [], marketplaceLoadErrors: []})), + reconcilePlugins: vi.fn(async () => ({changedPlugins: [], failedRemotePluginIds: [], failedMaterializationRemotePluginIds: []})), + reloadMcpServers: vi.fn(async () => {}), +}); +describe("runtime extension", () => { + it("rejects arbitrary methods, extra native parameters and missing identity", () => { + for (const input of [{sessionId: "s", resource: "config"}, {resource: "mcp"}, {sessionId: "s", resource: "mcp", threadId: "other"}]) { + expect(runtimeReadParser.safeParse(input).success).toBe(false); + } + expect(runtimeControlParser.safeParse({sessionId: "s", action: "delete"}).success).toBe(false); + }); + it("preserves unavailable context measurements as null", async () => { + const result = await readRuntime(client(), session, {sessionId: "s", resource: "context"}, new AbortController().signal); + expect(result).toMatchObject({data: {totalTokens: null, maxTokens: null, categories: null}}); + }); + it("refreshes skills for the actual session roots", async () => { + const native = client(); + await controlRuntime(native, session, {sessionId: "s", action: "reloadSkills"}); + expect(native.listSkills).toHaveBeenCalledWith({cwds: ["/workspace", "/extra"], forceReload: true}); + }); + it("does not claim MCP readiness from a reload acknowledgement", async () => { + const native = client(); + expect(await controlRuntime(native, session, {sessionId: "s", action: "reconnectMcp"})).toMatchObject({data: {scope: "provider", connectionsReady: false}}); + expect(native.reloadMcpServers).toHaveBeenCalledOnce(); + }); + it("rejects mismatched sessions before the native call", async () => { + const native = client(); + await expect(controlRuntime(native, session, {sessionId: "other", action: "reloadSkills"})).rejects.toThrow("Session mismatch"); + expect(native.listSkills).not.toHaveBeenCalled(); + }); + it("does not start a cancelled status read", async () => { + const native = client(); const abort = new AbortController(); abort.abort(); + expect(await readRuntime(native, session, {sessionId: "s", resource: "commands"}, abort.signal)).toMatchObject({reason: "cancelled"}); + expect(native.listSkills).not.toHaveBeenCalled(); + }); + it("does not publish the old provider's answer after replacement", async () => { + const native = client(); let current = true; + native.listSkills.mockImplementation(async () => {current = false; return {data: []};}); + expect(await readRuntime(native, session, {sessionId: "s", resource: "commands"}, new AbortController().signal, () => current)).toMatchObject({reason: "stale"}); + }); +}); diff --git a/src/__tests__/scenarios/baseline.ts b/src/__tests__/scenarios/baseline.ts index a7fe89b96..0a69b24db 100644 --- a/src/__tests__/scenarios/baseline.ts +++ b/src/__tests__/scenarios/baseline.ts @@ -1,3 +1,7 @@ +import {fileRevertCapability} from "../../SessionFileRevert"; +import {runtimeCapability} from "../../SessionRuntime"; +import {archiveCapability} from "../../SessionArchive"; +import {sessionDiscoveryCapability} from "../../SessionDiscovery"; import {canonical, type RecordedMessage} from "./scenario-harness"; /** @@ -141,6 +145,16 @@ const MCP_COMMAND = { export function withFeatureChanges(messages: RecordedMessage[]): RecordedMessage[] { const baselineMcp = JSON.stringify(canonical(BASELINE_MCP_COMMAND)); return messages.map(message => { + if (message.direction === "response" && message.method === "initialize") { + const params = structuredClone(message.params) as Json; + const meta = params["_meta"] as Json; + meta["runtime"] = runtimeCapability(); + meta["archive"] = archiveCapability(); + meta["fileRevert"] = fileRevertCapability(); + meta["discovery"] = sessionDiscoveryCapability(); + (meta["steering"] as Json)["idleBehavior"] = ["promptRequired"]; + return {...message, params}; + } const update = sessionUpdate(message); if (update === undefined || update["sessionUpdate"] !== "available_commands_update") return message; const commands = (update["availableCommands"] as unknown[]).map(command => diff --git a/src/__tests__/scenarios/data/air/agent-message-and-reasoning.jsonl b/src/__tests__/scenarios/data/air/agent-message-and-reasoning.jsonl index 89251c7e9..67092c0fe 100644 --- a/src/__tests__/scenarios/data/air/agent-message-and-reasoning.jsonl +++ b/src/__tests__/scenarios/data/air/agent-message-and-reasoning.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/background-terminal.jsonl b/src/__tests__/scenarios/data/air/background-terminal.jsonl index 133c06dff..8986d133b 100644 --- a/src/__tests__/scenarios/data/air/background-terminal.jsonl +++ b/src/__tests__/scenarios/data/air/background-terminal.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/collab-agent.jsonl b/src/__tests__/scenarios/data/air/collab-agent.jsonl index 3ce0964a8..b506fea43 100644 --- a/src/__tests__/scenarios/data/air/collab-agent.jsonl +++ b/src/__tests__/scenarios/data/air/collab-agent.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/collab-controls.jsonl b/src/__tests__/scenarios/data/air/collab-controls.jsonl index 44e3c7aee..d7f789cbc 100644 --- a/src/__tests__/scenarios/data/air/collab-controls.jsonl +++ b/src/__tests__/scenarios/data/air/collab-controls.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/command-approval.jsonl b/src/__tests__/scenarios/data/air/command-approval.jsonl index 6c39efeae..715411f9f 100644 --- a/src/__tests__/scenarios/data/air/command-approval.jsonl +++ b/src/__tests__/scenarios/data/air/command-approval.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/command-failed.jsonl b/src/__tests__/scenarios/data/air/command-failed.jsonl index 6c9b7f00a..2b976ca80 100644 --- a/src/__tests__/scenarios/data/air/command-failed.jsonl +++ b/src/__tests__/scenarios/data/air/command-failed.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/command-output-stdin.jsonl b/src/__tests__/scenarios/data/air/command-output-stdin.jsonl index 0954637b7..44379acf5 100644 --- a/src/__tests__/scenarios/data/air/command-output-stdin.jsonl +++ b/src/__tests__/scenarios/data/air/command-output-stdin.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/command-without-streamed-output.jsonl b/src/__tests__/scenarios/data/air/command-without-streamed-output.jsonl index 0b78648dd..5942fc210 100644 --- a/src/__tests__/scenarios/data/air/command-without-streamed-output.jsonl +++ b/src/__tests__/scenarios/data/air/command-without-streamed-output.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/context-compaction.jsonl b/src/__tests__/scenarios/data/air/context-compaction.jsonl index 6b4934bef..9499ff51e 100644 --- a/src/__tests__/scenarios/data/air/context-compaction.jsonl +++ b/src/__tests__/scenarios/data/air/context-compaction.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/dynamic-tool.jsonl b/src/__tests__/scenarios/data/air/dynamic-tool.jsonl index 77c24d776..14167c453 100644 --- a/src/__tests__/scenarios/data/air/dynamic-tool.jsonl +++ b/src/__tests__/scenarios/data/air/dynamic-tool.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/file-change-approval.jsonl b/src/__tests__/scenarios/data/air/file-change-approval.jsonl index 84fb595f9..fca2fdb21 100644 --- a/src/__tests__/scenarios/data/air/file-change-approval.jsonl +++ b/src/__tests__/scenarios/data/air/file-change-approval.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/file-changes.jsonl b/src/__tests__/scenarios/data/air/file-changes.jsonl index d1a13e93e..f5e7b4f83 100644 --- a/src/__tests__/scenarios/data/air/file-changes.jsonl +++ b/src/__tests__/scenarios/data/air/file-changes.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/fuzzy-file-search.jsonl b/src/__tests__/scenarios/data/air/fuzzy-file-search.jsonl index 983d812b7..eef4ce80b 100644 --- a/src/__tests__/scenarios/data/air/fuzzy-file-search.jsonl +++ b/src/__tests__/scenarios/data/air/fuzzy-file-search.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/goal-update.jsonl b/src/__tests__/scenarios/data/air/goal-update.jsonl index fafc769bd..8e02886a4 100644 --- a/src/__tests__/scenarios/data/air/goal-update.jsonl +++ b/src/__tests__/scenarios/data/air/goal-update.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/guardian-review.jsonl b/src/__tests__/scenarios/data/air/guardian-review.jsonl index e8c3c572a..37ecf7e92 100644 --- a/src/__tests__/scenarios/data/air/guardian-review.jsonl +++ b/src/__tests__/scenarios/data/air/guardian-review.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/history-replay.jsonl b/src/__tests__/scenarios/data/air/history-replay.jsonl index 077b959a5..a75be75e3 100644 --- a/src/__tests__/scenarios/data/air/history-replay.jsonl +++ b/src/__tests__/scenarios/data/air/history-replay.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/load","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/image-generation.jsonl b/src/__tests__/scenarios/data/air/image-generation.jsonl index ef06abf75..b2e206d8a 100644 --- a/src/__tests__/scenarios/data/air/image-generation.jsonl +++ b/src/__tests__/scenarios/data/air/image-generation.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/image-view.jsonl b/src/__tests__/scenarios/data/air/image-view.jsonl index 7d78698ae..9a644c604 100644 --- a/src/__tests__/scenarios/data/air/image-view.jsonl +++ b/src/__tests__/scenarios/data/air/image-view.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/late-subagent-update.jsonl b/src/__tests__/scenarios/data/air/late-subagent-update.jsonl index 398b5da13..6a370e78a 100644 --- a/src/__tests__/scenarios/data/air/late-subagent-update.jsonl +++ b/src/__tests__/scenarios/data/air/late-subagent-update.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/mcp-elicitation.jsonl b/src/__tests__/scenarios/data/air/mcp-elicitation.jsonl index 7ca3c2a52..f3bd64604 100644 --- a/src/__tests__/scenarios/data/air/mcp-elicitation.jsonl +++ b/src/__tests__/scenarios/data/air/mcp-elicitation.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/mcp-startup-failure.jsonl b/src/__tests__/scenarios/data/air/mcp-startup-failure.jsonl index 195a3b205..b9cc913bb 100644 --- a/src/__tests__/scenarios/data/air/mcp-startup-failure.jsonl +++ b/src/__tests__/scenarios/data/air/mcp-startup-failure.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"content":[{"content":{"text":"[codex-acp forwarded startup error] MCP server `db` failed to start: boom","type":"text"},"type":"content"}],"kind":"other","sessionUpdate":"tool_call","status":"failed","title":"mcp__db__startup","toolCallId":"mcp_startup.db."}}} diff --git a/src/__tests__/scenarios/data/air/mcp-tool-approval.jsonl b/src/__tests__/scenarios/data/air/mcp-tool-approval.jsonl index 8fb3aa3c5..80dce2729 100644 --- a/src/__tests__/scenarios/data/air/mcp-tool-approval.jsonl +++ b/src/__tests__/scenarios/data/air/mcp-tool-approval.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/mcp-tool.jsonl b/src/__tests__/scenarios/data/air/mcp-tool.jsonl index a47194ded..4a133826f 100644 --- a/src/__tests__/scenarios/data/air/mcp-tool.jsonl +++ b/src/__tests__/scenarios/data/air/mcp-tool.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/mcp-url-elicitation.jsonl b/src/__tests__/scenarios/data/air/mcp-url-elicitation.jsonl index 0a0d6edd8..c0e30ed53 100644 --- a/src/__tests__/scenarios/data/air/mcp-url-elicitation.jsonl +++ b/src/__tests__/scenarios/data/air/mcp-url-elicitation.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/native-subagent-session.jsonl b/src/__tests__/scenarios/data/air/native-subagent-session.jsonl index 35aedeb60..d6360e99a 100644 --- a/src/__tests__/scenarios/data/air/native-subagent-session.jsonl +++ b/src/__tests__/scenarios/data/air/native-subagent-session.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/nested-subagent-session.jsonl b/src/__tests__/scenarios/data/air/nested-subagent-session.jsonl index 7e77d67b8..0f9860399 100644 --- a/src/__tests__/scenarios/data/air/nested-subagent-session.jsonl +++ b/src/__tests__/scenarios/data/air/nested-subagent-session.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/network-and-sandbox-approval.jsonl b/src/__tests__/scenarios/data/air/network-and-sandbox-approval.jsonl index 812e00748..379fd626d 100644 --- a/src/__tests__/scenarios/data/air/network-and-sandbox-approval.jsonl +++ b/src/__tests__/scenarios/data/air/network-and-sandbox-approval.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/plan-deltas-and-turn-plan.jsonl b/src/__tests__/scenarios/data/air/plan-deltas-and-turn-plan.jsonl index e41203ccc..7a1acd0b2 100644 --- a/src/__tests__/scenarios/data/air/plan-deltas-and-turn-plan.jsonl +++ b/src/__tests__/scenarios/data/air/plan-deltas-and-turn-plan.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/plan-review-permission.jsonl b/src/__tests__/scenarios/data/air/plan-review-permission.jsonl index 426be38aa..2c2b2812e 100644 --- a/src/__tests__/scenarios/data/air/plan-review-permission.jsonl +++ b/src/__tests__/scenarios/data/air/plan-review-permission.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"plan","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/plan-stream.jsonl b/src/__tests__/scenarios/data/air/plan-stream.jsonl index 076d251cc..bdd6b2798 100644 --- a/src/__tests__/scenarios/data/air/plan-stream.jsonl +++ b/src/__tests__/scenarios/data/air/plan-stream.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/read-search-list.jsonl b/src/__tests__/scenarios/data/air/read-search-list.jsonl index cecc0a9d0..81d5d8e1e 100644 --- a/src/__tests__/scenarios/data/air/read-search-list.jsonl +++ b/src/__tests__/scenarios/data/air/read-search-list.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/subagent-activity.jsonl b/src/__tests__/scenarios/data/air/subagent-activity.jsonl index 07552742d..3280160b7 100644 --- a/src/__tests__/scenarios/data/air/subagent-activity.jsonl +++ b/src/__tests__/scenarios/data/air/subagent-activity.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/web-search.jsonl b/src/__tests__/scenarios/data/air/web-search.jsonl index 53509d316..f30c28549 100644 --- a/src/__tests__/scenarios/data/air/web-search.jsonl +++ b/src/__tests__/scenarios/data/air/web-search.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/scenario-harness.ts b/src/__tests__/scenarios/scenario-harness.ts index 6dc629241..fee262ed9 100644 --- a/src/__tests__/scenarios/scenario-harness.ts +++ b/src/__tests__/scenarios/scenario-harness.ts @@ -29,6 +29,7 @@ export const AIR_CAPABILITY_NAMES = [ "rawInputRendering", "planContentDelta", "codexHooks", + "sessionRewind", ]; /** The client capabilities of each client profile. */ @@ -96,7 +97,10 @@ export async function runScenario( } const messages = await runInWorkspace(replaceText(scenario, WS, workspace), profile, workspace); // A Git patch names the paths without the leading slash. - return replaceText(replaceText(messages, workspace, WS), workspace.slice(1), WS.slice(1)); + // Native locations use platform separators; Git patch headers always use '/'. + const local = replaceText(replaceText(messages, workspace, WS), workspace.replaceAll("\\", "/"), WS); + return JSON.parse(JSON.stringify(local, (_key, value) => typeof value === "string" + ? value.replaceAll("/workspace\\", "/workspace/").replaceAll("/workspace/src\\", "/workspace/src/").replaceAll("a//workspace/", "a/workspace/").replaceAll("b//workspace/", "b/workspace/").replaceAll("from /workspace/", "from workspace/").replaceAll("to /workspace/", "to workspace/") : value)); } finally { fs.rmSync(workspace, {recursive: true, force: true}); } diff --git a/src/index.ts b/src/index.ts index bee160fe9..cfdb567fb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,8 @@ #!/usr/bin/env node +import {SESSION_FILE_REVERT_METHOD, sessionFileRevertParser} from "./SessionFileRevert"; +import {SESSION_QUEUE_METHOD, parseSessionQueueRequest} from "./SessionQueue"; +import {SESSION_SEARCH_METHOD, SESSION_ATTACHMENTS_METHOD, sessionSearchParser, sessionAttachmentsParser} from "./SessionDiscovery"; +import {SESSION_ARCHIVE_METHOD, SESSION_UNARCHIVE_METHOD, sessionArchiveParser} from "./SessionArchive"; import * as acp from "@agentclientprotocol/sdk"; import {z} from "zod"; @@ -20,6 +24,9 @@ import { } from "./AcpExtensions"; import {ASYNC_TASK_STOP_METHOD} from "./async-tasks/AsyncTaskExtension"; +import {RUNTIME_READ_METHOD, RUNTIME_CONTROL_METHOD, runtimeReadParser, runtimeControlParser} from "./SessionRuntime"; +import {SESSION_REWIND_METHOD} from "./SessionRewind"; + const emptyExtensionParamsParser = z.preprocess( (params) => params ?? {}, z.object({}).passthrough() @@ -52,6 +59,17 @@ const asyncTaskStopParamsParser = z.object({ asyncTaskId: z.string().trim().min(1), }).passthrough(); +const sessionHistoryPointParser = z.object({ + messageId: z.string().trim().min(1), + messageFingerprint: z.string().regex(/^sha256:[0-9a-f]{64}$/), + messageOccurrence: z.number().int().positive(), +}); +const sessionRewindParamsParser = z.object({ + sessionId: z.string().trim().min(1), + beforeMessage: sessionHistoryPointParser, + resumeAtMessage: sessionHistoryPointParser.optional(), +}).passthrough(); + const hooksListParamsParser = z.object({cwd: z.string().trim().min(1)}); const hooksTrustParamsParser = z.object({ cwd: z.string().trim().min(1), @@ -178,6 +196,15 @@ function startAcpServer() { .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(ASYNC_TASK_STOP_METHOD, asyncTaskStopParamsParser, (ctx) => getAgent().extMethod(ASYNC_TASK_STOP_METHOD, ctx.params)) + .onRequest(SESSION_FILE_REVERT_METHOD, sessionFileRevertParser, (ctx) => getAgent().revertFiles(ctx.params)) + .onRequest(SESSION_QUEUE_METHOD, z.unknown().transform(value => parseSessionQueueRequest(value)), (ctx) => getAgent().manageSessionQueue(ctx.params, ctx.signal)) + .onRequest(SESSION_SEARCH_METHOD, sessionSearchParser, (ctx) => getAgent().searchSessionHistory(ctx.params)) + .onRequest(SESSION_ATTACHMENTS_METHOD, sessionAttachmentsParser, (ctx) => getAgent().manageSessionAttachments(ctx.params)) + .onRequest(SESSION_ARCHIVE_METHOD, sessionArchiveParser, (ctx) => getAgent().archiveSession(ctx.params.sessionId, true)) + .onRequest(SESSION_UNARCHIVE_METHOD, sessionArchiveParser, (ctx) => getAgent().archiveSession(ctx.params.sessionId, false)) + .onRequest(RUNTIME_READ_METHOD, runtimeReadParser, (ctx) => getAgent().readSessionRuntime(ctx.params, ctx.signal)) + .onRequest(RUNTIME_CONTROL_METHOD, runtimeControlParser, (ctx) => getAgent().controlSessionRuntime(ctx.params)) + .onRequest(SESSION_REWIND_METHOD, sessionRewindParamsParser, (ctx) => getAgent().extMethod(SESSION_REWIND_METHOD, ctx.params)) .onRequest(CODEX_HOOKS_LIST_METHOD, hooksListParamsParser, (ctx) => getAgent().listHooks(ctx.params.cwd)) .onRequest(CODEX_HOOKS_TRUST_METHOD, hooksTrustParamsParser, (ctx) => getAgent().trustHooks(ctx.params.cwd, ctx.params.hooks)) .onRequest(GOAL_CONTROL_METHOD, goalControlParamsParser, (ctx) => getAgent().extMethod(GOAL_CONTROL_METHOD, ctx.params)) diff --git a/src/tool-calls/reporters/FuzzySearchReporter.ts b/src/tool-calls/reporters/FuzzySearchReporter.ts index ec03b2114..19f4d34aa 100644 --- a/src/tool-calls/reporters/FuzzySearchReporter.ts +++ b/src/tool-calls/reporters/FuzzySearchReporter.ts @@ -19,7 +19,7 @@ export class FuzzySearchReporter { report: started ? "start" : "update", title: searchTitle(event.query, null), status: "in_progress", - locations: event.files.map(file => path.isAbsolute(file.path) ? file.path : path.join(file.root, file.path)), + locations: event.files.map(file => path.isAbsolute(file.path) ? file.path : (file.root.startsWith("/") ? path.posix : path).join(file.root, file.path)), }; return started ? {...facts, kind: "search", input: {query: event.query}} : facts; } From f52582c696299d8eab15843fa8d37b762c15fe58 Mon Sep 17 00:00:00 2001 From: Wolf-L <50284298+Wolf-L@users.noreply.github.com> Date: Fri, 9 Oct 2026 07:13:14 +0800 Subject: [PATCH 6/8] refactor: isolate same-session rewind core from session extensions --- README.md | 2 - docs/native-session-extensions.md | 39 --- docs/native-session-extensions.zh-CN.md | 31 -- docs/session-rewind-extension.md | 4 +- src/AcpExtensions.ts | 3 +- src/CodexAcpClient.ts | 21 -- src/CodexAcpServer.ts | 246 +------------- src/CodexAppServerClient.ts | 47 --- src/CodexJsonRpcConnection.ts | 23 -- src/DesktopAttachmentHistory.ts | 2 +- src/SessionArchive.ts | 7 - src/SessionDiscovery.ts | 73 ----- src/SessionFileRevert.ts | 300 ------------------ src/SessionQueue.ts | 191 ----------- src/SessionRuntime.ts | 119 ------- .../CodexACPAgent/CodexAcpClient.test.ts | 7 +- .../CodexACPAgent/initialize.test.ts | 14 - .../session-rewind-lifecycle.test.ts | 117 +------ src/__tests__/SessionDiscovery.test.ts | 31 -- src/__tests__/SessionFileRevert.test.ts | 175 ---------- src/__tests__/SessionQueue.test.ts | 196 ------------ src/__tests__/SessionRuntime.test.ts | 51 --- src/__tests__/scenarios/baseline.ts | 14 - .../air/agent-message-and-reasoning.jsonl | 2 +- .../data/air/background-terminal.jsonl | 2 +- .../scenarios/data/air/collab-agent.jsonl | 2 +- .../scenarios/data/air/collab-controls.jsonl | 2 +- .../scenarios/data/air/command-approval.jsonl | 2 +- .../scenarios/data/air/command-failed.jsonl | 2 +- .../data/air/command-output-stdin.jsonl | 2 +- .../air/command-without-streamed-output.jsonl | 2 +- .../data/air/context-compaction.jsonl | 2 +- .../scenarios/data/air/dynamic-tool.jsonl | 2 +- .../data/air/file-change-approval.jsonl | 2 +- .../scenarios/data/air/file-changes.jsonl | 2 +- .../data/air/fuzzy-file-search.jsonl | 2 +- .../scenarios/data/air/goal-update.jsonl | 2 +- .../scenarios/data/air/guardian-review.jsonl | 2 +- .../scenarios/data/air/history-replay.jsonl | 2 +- .../scenarios/data/air/image-generation.jsonl | 2 +- .../scenarios/data/air/image-view.jsonl | 2 +- .../data/air/late-subagent-update.jsonl | 2 +- .../scenarios/data/air/mcp-elicitation.jsonl | 2 +- .../data/air/mcp-startup-failure.jsonl | 2 +- .../data/air/mcp-tool-approval.jsonl | 2 +- .../scenarios/data/air/mcp-tool.jsonl | 2 +- .../data/air/mcp-url-elicitation.jsonl | 2 +- .../data/air/native-subagent-session.jsonl | 2 +- .../data/air/nested-subagent-session.jsonl | 2 +- .../air/network-and-sandbox-approval.jsonl | 2 +- .../data/air/plan-deltas-and-turn-plan.jsonl | 2 +- .../data/air/plan-review-permission.jsonl | 2 +- .../scenarios/data/air/plan-stream.jsonl | 2 +- .../scenarios/data/air/read-search-list.jsonl | 2 +- .../data/air/subagent-activity.jsonl | 2 +- .../scenarios/data/air/web-search.jsonl | 2 +- src/__tests__/scenarios/scenario-harness.ts | 5 +- src/index.ts | 13 - .../reporters/FuzzySearchReporter.ts | 2 +- 59 files changed, 53 insertions(+), 1746 deletions(-) delete mode 100644 docs/native-session-extensions.md delete mode 100644 docs/native-session-extensions.zh-CN.md delete mode 100644 src/SessionArchive.ts delete mode 100644 src/SessionDiscovery.ts delete mode 100644 src/SessionFileRevert.ts delete mode 100644 src/SessionQueue.ts delete mode 100644 src/SessionRuntime.ts delete mode 100644 src/__tests__/SessionDiscovery.test.ts delete mode 100644 src/__tests__/SessionFileRevert.test.ts delete mode 100644 src/__tests__/SessionQueue.test.ts delete mode 100644 src/__tests__/SessionRuntime.test.ts diff --git a/README.md b/README.md index c1398636e..b87caed3c 100644 --- a/README.md +++ b/README.md @@ -108,5 +108,3 @@ See [AIR extensions](docs/air-extensions.md#async-tasks) for the capability, lif ## License By contributing, you agree that your contributions will be licensed under the Apache 2.0 License. - -Local native-session test extensions: [English](docs/native-session-extensions.md) | [简体中文](docs/native-session-extensions.zh-CN.md). diff --git a/docs/native-session-extensions.md b/docs/native-session-extensions.md deleted file mode 100644 index f2a894189..000000000 --- a/docs/native-session-extensions.md +++ /dev/null @@ -1,39 +0,0 @@ -# Local native session extensions - -This local test build integrates PR #508 on main `51aa211` (adapter 2.1.1, locked Codex 0.160.1, ACP SDK 1.5.0). It preserves current-main hooks, attachments, tool-call reports and compaction handling. No Codeg changes, global installation, push or new PR are included. - -## Negotiated operations - -Read the `initialize` metadata before invoking any extension. These are named methods with strict schemas, not a generic native RPC tunnel. - -| Capability | Method | Contract | -|---|---|---| -| AIR `sessionRewind` | `_session/rewind` | Same ID, exclusive `beforeMessage`, optional `resumeAtMessage`; `{rewound:true}` only after persisted-prefix verification. | -| `_meta.steering.idleBehavior` | `_session/steering` | Opt in with `_meta.steering.idleBehavior:"promptRequired"`; idle returns `{outcome:"promptRequired"}` without starting a model request. Non-opted clients retain the existing idle new-turn behavior. | -| `_meta.runtime` | `_session/runtime/read` | `resource`: context, usage, mcp, commands, plugins. Unknown measurements stay null. Commands are native skills; this is not a list of every Desktop UI command. | -| `_meta.runtime` | `_session/runtime/control` | `action`: reloadSkills, reconnectMcp, reloadPlugins. Skill reload republishes available commands. MCP/plugins are provider-wide idle operations; their acknowledgement is not runtime readiness. | -| `_meta.archive` | `_session/archive`, `_session/unarchive` | `{sessionId}`; reversible native archive, same ID. Existing standard session/delete keeps its prior archive-backed behavior. No permanent delete. Archive closes local routing; unarchive requires load to reopen. | -| `_meta.discovery` | `_session/search` | Bounded searchTerm/cursor/limit/archived. Searches user sources appServer/cli/vscode. Current native search can miss text retained only through ancestor rollout lineage after rewind. | -| `_meta.discovery` | `_session/attachments` | list/add/remove native metadata only. Does not create/delete a file, worktree, PR or prompt attachment. Loaded-session ownership required. | -| `_meta.queue` | `_session/queue` | Only list is advertised after actual-native version and positive nonmutating method probe. Writes/start return unsupported before native mutation. Pending native queues prevent ACP load/resume to avoid unowned auto-dispatch. | -| `_meta.fileRevert` | `_session/files/revert` | `{sessionId,toolCallId,dryRun,previewToken?}`; reverse one completed native fileChange tool's recorded text patches in Git. Preview token required for apply. History stays unchanged. | - -## File reversal - -Preview and commit re-read native history and fingerprint affected files. A stale preview, conflict, unknown change kind, binary/unsupported patch or unsafe path fails closed for the entire batch. Git uses reverse check/apply without --index/--cached/--3way, preserving the user's index and unrelated bytes. Reverted text follows Git attributes/autocrlf and is not promised to be byte-identical to its original encoding/newlines. Shell changes, unrecorded subagents and whole-workspace checkpoints are outside coverage. - -The server reserves the provider while validating loaded native threads and background terminals. External editors and unrelated processes are not controlled by this fence; Git conflict checks and fresh fingerprints narrow but do not eliminate an OS-level race. - -## Timeouts and recovery - -Runtime mutations have a 30-second deadline. Timeout invalidates local sessions and terminates the owned provider; reconnect the ACP adapter and explicitly load a session. No mutation is replayed. Windows uses taskkill /T for the owned launcher tree; the current POSIX kill path only guarantees termination of the launcher, so equivalent process-tree termination is not yet proven. Read timeouts return unavailable. A history mutation with an uncertain outcome blocks further sends until successful load/reconciliation. - -## Desktop parity boundaries - -Supported and locally exercised: same-ID first/latest/historical/repeated rewind, cancel-and-settle, cold load, resend context truncation, runtime status/control, archive/unarchive, attachment metadata persistence, safe idle steering, native search of current rollout, queue inspection and per-tool text patch reversal. - -Not claimed: full native queue automatic-turn adoption, realtime audio/voice, timeline API, complete Desktop project/worktree/UI management, whole-session file checkpoint restore, binary reverse patches, all child process/background writer isolation, or complete search over retained ancestor rollouts. A native schema method does not establish an implemented ACP handler. - -## Validation - -Full suite: 82 test files passed, 8 skipped; 1342 tests passed, 39 skipped on Windows. Typecheck/build passed. Additional file/lifecycle regression: 28 passed. Isolated ACP plus native plus localhost mock model covered 0.160.1 and CODEX_PATH 0.159.3; no real model or real user session was used. Independent focused review passed 6/6 reproductions, including real owned-child termination. The native file RPC E2E passed 9 checks (actual apply_patch, persisted replay, preview, stale/conflict refusal, index/unrelated-byte preservation and same-session continuation). These are scoped findings, not proof of every interleaving. diff --git a/docs/native-session-extensions.zh-CN.md b/docs/native-session-extensions.zh-CN.md deleted file mode 100644 index 8aa03e376..000000000 --- a/docs/native-session-extensions.zh-CN.md +++ /dev/null @@ -1,31 +0,0 @@ -# 本地原生会话扩展 - -本测试版在 main `51aa211` 上整合 PR #508,adapter 2.1.1,冻结 Codex 0.160.1、SDK 1.5.0。保留主线 hooks、附件回放、工具调用协议与压缩处理。没有改 Codeg、全局安装、push 或新 PR。 - -## 已接入 - -- `_session/rewind`:保留原接口和作者提交;同 ID 回退首条、历史、最近消息。验证指纹/保留边界,生成中先取消并等待旧 prompt 清理,再调用原生回退并核验持久前缀。 -- `_session/steering`:opt-in `_meta.steering.idleBehavior:"promptRequired"`,空闲返回 promptRequired,不偷偷启动模型。未 opt-in 保留旧行为。 -- runtime read:context、usage、mcp、commands(native skills)、plugins。未知用量保留 null,不捏造分类。 -- runtime control:reloadSkills、reconnectMcp、reloadPlugins;后两者要求 provider 空闲,确认回执不等于 runtimeReady。技能刷新后重发可用命令。 -- archive/unarchive:原生可逆归档、同 ID;不新增永久删除。旧 session/delete 仍保持原来的 archive 语义。 -- search:限定参数和 appServer/cli/vscode 来源;已验证当前 rollout synthetic 文本命中。回退后仅保留在祖先 rollout 的文本可能被原生搜索漏掉。 -- attachments:原生 thread 元数据 list/add/remove、冷重启持久化;不创建/删除磁盘文件、PR 或 worktree。 -- queue:只在真实 native 版本和无副作用探测通过后广告 list。写入/start 在 native mutation 前 unsupported;已有 durable pending queue 阻止 ACP load/resume,避免无人归属的自动生成。 -- files/revert:限定单个已完成 native fileChange 工具的 Git 文本补丁。preview token 绑定原生补丁与文件指纹,提交前复核;不改会话历史。 - -## 文件语义与恢复 - -文件撤销不等于 whole checkpoint:不覆盖 shell、未记录子代理、二进制补丁。Git reverse check/apply 不用 --index/--cached/--3way;用户 index 和无关文件严格保持。恢复后的文本走 Git 属性/autocrlf,不保证原始字节或换行完全一致。未知 change kind、缺失 diff、冲突、陈旧 token、越界/链接路径整批拒绝。 - -runtime mutation 30 秒超时后使会话失效并终止 provider,用户重连 ACP 再显式 load;不会自动重放 mutation。Windows 终止 owned 进程树;POSIX 当前仅保证 launcher 终止,子树级保证未验证。历史回退 dispatch 后失败可能已改变磁盘,必须成功 load 对账再继续发送。 - -## 尚未实现的 Desktop 能力 - -完整队列自动回合 observer/adoption、realtime voice、timeline、Desktop 项目/worktree/UI 全部管理、全工作区文件 checkpoint、二进制反向恢复、外部写入进程完全隔离、祖先 rollout 全文搜索均不宣称完成。native schema 存在某方法不等于本 ACP 已支持。 - -## 验证 - -Windows 完整套件 82 文件通过/8 跳过,1342 项通过/39 跳过;typecheck/build 通过;文件/lifecycle 增量 28 项通过。0.160.1 与 CODEX_PATH 0.159.3 的真实 ACP+native+localhost mock 模型隔离实验通过,未调用真实模型或用户会话。独立定向 review 六项复现全通过(含真实 owned-child 终止);独立文件 RPC E2E 九项通过,覆盖真实 apply_patch、持久回放、预览、陈旧/冲突拒绝、index/无关文件字节保持、同会话继续;仍不代表所有交错均已验证。 - -协议细节见 [English](native-session-extensions.md);原 rewind 作者 Nikita Ashikhmin,来源 [PR #508](https://github.com/agentclientprotocol/codex-acp/pull/508),四条原提交保留在历史中。 diff --git a/docs/session-rewind-extension.md b/docs/session-rewind-extension.md index cd21a7d2f..f32f4b4df 100644 --- a/docs/session-rewind-extension.md +++ b/docs/session-rewind-extension.md @@ -58,10 +58,10 @@ After a successful response, the client can remove the same transcript suffix an ## Lifecycle and compatibility -An active prompt is cancelled only after the target validates; the adapter waits for prompt cleanup before native mutation. A pending turn/start registration is rejected, because an unacknowledged native start could write after the rewind. Competing lifecycle/settings/steering/runtime operations are fenced. Provider replacement waits for an existing rewind. +An active prompt is cancelled only after the target validates; the adapter waits for prompt cleanup before native mutation. A pending turn/start registration is rejected, because an unacknowledged native start could write after the rewind. Competing lifecycle/settings/steering operations are fenced. Provider replacement waits for an existing rewind. `thread/rollback` is used only when native history explicitly reports `legacy`. It is a separately typed compatibility request for old `CODEX_PATH` binaries, not part of current generated API types. Unknown history mode, arbitrary errors, malformed responses and `thread/revert` failures never trigger rollback. -History rewind does not restore files. See [native session extensions](native-session-extensions.md) for the separate, previewed single-tool Git patch reversal. +History rewind does not restore files. The interface and original implementation come from [Nikita Ashikhmin's PR #508](https://github.com/agentclientprotocol/codex-acp/pull/508), preserved as four commits in this branch. This local integration merges current main and adds validation/lifecycle safeguards; it is not a new incompatible rewind protocol. diff --git a/src/AcpExtensions.ts b/src/AcpExtensions.ts index 95363b81d..cca4297fd 100644 --- a/src/AcpExtensions.ts +++ b/src/AcpExtensions.ts @@ -128,11 +128,10 @@ export async function legacySetSessionModel( export type SessionSteerRequest = { sessionId: SessionId; prompt: ContentBlock[]; - _meta?: {steering?: {idleBehavior?: "promptRequired"}}; } export type SessionSteeringResponse = { - outcome: "injected" | "startedNewTurn" | "promptRequired" | "failed"; + outcome: "injected" | "startedNewTurn" | "failed"; } export type SessionSteeringExtRequest = { diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index e84fca922..18fb04e42 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -1,4 +1,3 @@ -import {probeSessionQueueSupport, type SessionQueueSupport} from "./SessionQueue"; import { type ApiKeyAuthRequest, CODEX_API_KEY_ENV_VAR, @@ -167,8 +166,6 @@ export class CodexAcpClient { return this.codexClient; } - queueSupport: SessionQueueSupport = {nativeVersion: null, actions: []}; - private readonly defaultClientInfo: ClientInfo = { name: `${packageJson.name}`, title: "Codex ACP", version: `${packageJson.version}` }; @@ -186,10 +183,6 @@ export class CodexAcpClient { } }); this.configPath = response?.codexHome ?? null; - // Native userAgent uses the requesting client name when Desktop originator env is absent. - // Its /version segment is the running native version, not clientInfo.version. - const version = response?.userAgent?.match(/^[^/]+\/(\d+\.\d+\.\d+)\s+\(/)?.[1]; - this.queueSupport = await probeSessionQueueSupport(version, action => action === "list" ? this.codexClient.probeQueueAction(action) : Promise.resolve(false)); } getHomePath(): string | null { @@ -798,20 +791,6 @@ export class CodexAcpClient { } } - async archiveSession(sessionId: string): Promise { - await this.codexClient.threadArchive({threadId: sessionId}); - } - async unarchiveSession(sessionId: string): Promise { - const response = await this.codexClient.threadUnarchive({threadId: sessionId}); - if (response.thread.id !== sessionId) throw new Error("Unarchive returned a different thread"); - } - async installedPlugins(cwds: string[]) { - return this.codexClient.pluginInstalled({cwds}); - } - async reconcilePlugins() { - return this.codexClient.pluginReconcile({reason: "ACP runtime refresh"}); - } - async deleteSession(sessionId: string): Promise { try { await this.codexClient.threadArchive({threadId: sessionId}); diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index 5694005fb..9d8344506 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -1,14 +1,8 @@ -import {terminateCodexConnection} from "./CodexJsonRpcConnection"; -import {fileRevertCapability, revertSessionFiles, type SessionFileRevertRequest} from "./SessionFileRevert"; -import {parseSessionQueueRequest, runSessionQueue, sessionQueueCapability, type SessionQueueRequest} from "./SessionQueue"; -import {sessionDiscoveryCapability, searchSessions, sessionAttachments, type SessionSearchRequest, type SessionAttachmentsRequest} from "./SessionDiscovery"; -import {archiveCapability} from "./SessionArchive"; import * as acp from "@agentclientprotocol/sdk"; import {RequestError, type SessionId, type SessionModeState} from "@agentclientprotocol/sdk"; import {CodexEventHandler, type CompletedPlan} from "./CodexEventHandler"; import {attachmentFileUri} from "./DesktopAttachmentHistory"; import {userInputToContentBlocks} from "./UserInputContent"; -import {runtimeCapability, runtimeReadParser, runtimeControlParser, readRuntime, controlRuntime, type RuntimeReadRequest, type RuntimeControlRequest, type RuntimeResponse} from "./SessionRuntime"; import {CodexApprovalHandler} from "./permissions/CodexApprovalHandler"; import {PermissionLifecycleContext} from "./permissions/lifecycle"; import {CodexElicitationHandler} from "./CodexElicitationHandler"; @@ -363,11 +357,6 @@ export class CodexAcpServer { private codexProcessGeneration = 0; private initializeRequest: acp.InitializeRequest | null = null; private providerUpdate: Promise | null = null; - private providerRuntimeControl = false; - private providerCreationReservations = 0; - private providerInvalidated = false; - private providerReads = 0; - private readonly runtimeControls = new Set(); private readonly rewinds = new Map>(); private readonly rewindRecoveryRequired = new Set(); private readonly sessionOperations = new Map(); @@ -487,14 +476,8 @@ export class CodexAcpServer { }, authMethods: getCodexAuthMethods(_params.clientCapabilities), _meta: { - runtime: runtimeCapability(), - archive: archiveCapability(), - fileRevert: fileRevertCapability(), - discovery: sessionDiscoveryCapability(), - ...(sessionQueueCapability(this.codexAcpClient.queueSupport) ? {queue: sessionQueueCapability(this.codexAcpClient.queueSupport)} : {}), steering: { supported: true, - idleBehavior: ["promptRequired"], }, // Only AIR gets the AIR extension, see `docs/air-extensions.md`. ...(this.capabilities.airClient ? { @@ -521,41 +504,7 @@ export class CodexAcpServer { }; } - private assertProviderAvailable(): void { - if (this.providerInvalidated) throw RequestError.invalidRequest("Native provider was invalidated after a timeout; reconnect the ACP adapter"); - } - - private async invalidateProviderAfterTimeout(): Promise { - // Poison admission BEFORE disposing the transport: late native completions must never - // install or publish a session, even if process termination itself fails. - this.providerInvalidated = true; - for (const session of this.sessions.values()) { - session.asyncTasks.clear(); - this.bumpSessionGeneration(session.sessionId); - this.mcpSessionStartup.close(session.sessionId); - } - for (const prompt of this.activePrompts.values()) prompt.requestClose(); - for (const pending of this.pendingTurnStarts.values()) pending.resolve(null); - this.sessions.clear(); - this.pendingTurnStarts.clear(); - this.steeringQueues.clear(); - if (this.codexProcessState !== null) await terminateCodexConnection(this.codexProcessState.connection); - } - - private async boundedRuntimeControl(operation: () => Promise): Promise { - const pending = operation(); - // settledWithin observes the losing promise as well; it never becomes an unhandled rejection. - const result = await settledWithin(pending.then(value => ({value})), 30_000); - if (result === "pending") { - await this.invalidateProviderAfterTimeout(); - throw RequestError.internalError(undefined, "Native runtime operation timed out; provider terminated, reconnect the ACP adapter"); - } - return result.value; - } - private assertNotRewinding(sessionId: string): void { - this.assertProviderAvailable(); - if (this.providerRuntimeControl || this.runtimeControls.has(sessionId)) throw RequestError.invalidRequest("Runtime control is in progress"); if (this.rewinds.has(sessionId)) throw RequestError.invalidRequest(`Session ${sessionId} is rewinding`); } @@ -620,155 +569,6 @@ export class CodexAcpServer { } } - async revertFiles(request: SessionFileRevertRequest) { - while (this.providerUpdate !== null) await this.providerUpdate; - this.assertNotRewinding(request.sessionId); - const state = this.getSessionState(request.sessionId); - if (this.providerCreationReservations || this.rewinds.size || this.sessionOperations.size || this.providerReads || this.activePrompts.size - || this.pendingTurnStarts.size || this.closingSessions.size) throw RequestError.invalidRequest("Provider is busy; settle work before reverting files"); - this.providerRuntimeControl = true; - try { - // Include native-loaded child threads, not just root ACP sessions. Fail closed if - // this native version cannot prove that its background terminals are idle. - await this.boundedRuntimeControl(async () => { - let cursor: string | null = null; - const seen = new Set(); - do { - const page = await this.codexAcpClient.appServerClient.threadLoadedList({cursor, limit: 100}); - for (const threadId of page.data) { - const thread = await this.codexAcpClient.appServerClient.threadRead({threadId}); - if (thread.thread.status.type === "active") throw RequestError.invalidRequest("Native thread is active; settle work before reverting files"); - const terminals = await this.codexAcpClient.appServerClient.threadBackgroundTerminalsList({threadId, limit: 1}); - if (terminals.data.length || terminals.nextCursor) throw RequestError.invalidRequest("Native background work is active"); - } - cursor = page.nextCursor; - if (cursor && seen.has(cursor)) throw RequestError.internalError(undefined, "Repeated loaded-thread cursor"); - if (cursor) seen.add(cursor); - } while (cursor); - }); - const result = await revertSessionFiles(request, state, () => this.boundedRuntimeControl(() => this.codexAcpClient.appServerClient.threadReadWithHistory(state.sessionId))); - if (result.reason === "restore_outcome_unknown") this.rewindRecoveryRequired.add(state.sessionId); - return result; - } finally { this.providerRuntimeControl = false; } - } - - private async assertNoPendingNativeQueue(sessionId: string): Promise { - if (!this.codexAcpClient.queueSupport.actions.includes("list")) return; - if (this.sessionIsClosing(sessionId)) throw RequestError.invalidRequest(`Session ${sessionId} is closing`); - const generation = this.getSessionGeneration(sessionId); - const client = this.codexAcpClient; - let page; - try { page = await client.appServerClient.queueNative().list({threadId: sessionId, limit: 1}); } - catch (error) { - // Preserve the existing public invalid-session diagnostic; this is a read validation - // error, never a reason to resume, mutate history or skip pending-queue checks. - if (error instanceof Error && error.message.startsWith("invalid thread id:")) { - throw RequestError.invalidParams(undefined, "invalid session id"); - } - throw error; - } - if (this.codexAcpClient !== client || this.getSessionGeneration(sessionId) !== generation || this.sessionIsClosing(sessionId)) { - throw RequestError.invalidRequest(`Session ${sessionId} changed while checking its native queue`); - } - if (page.data.length || page.nextCursor) throw RequestError.invalidRequest("Native pending queue requires an owning native client; ACP auto-dispatch is not supported"); - } - - async manageSessionQueue(value: unknown, signal?: AbortSignal): Promise { - const request = parseSessionQueueRequest(value); - while (this.providerUpdate !== null) await this.providerUpdate; - this.assertNotRewinding(request.sessionId); - this.getSessionState(request.sessionId); - const capability = sessionQueueCapability(this.codexAcpClient.queueSupport); - if (!capability?.actions.includes(request.action)) return {status: "unsupported"}; - if (this.sessionIsClosing(request.sessionId) || this.sessionOperations.has(request.sessionId)) throw RequestError.invalidRequest("Session is busy"); - return this.sessionOperation(request.sessionId, async () => { - this.runtimeControls.add(request.sessionId); - try { return await runSessionQueue(request, {sessionId: request.sessionId, support: this.codexAcpClient.queueSupport, native: this.codexAcpClient.appServerClient.queueNative()}); } - finally { this.runtimeControls.delete(request.sessionId); } - }); - } - - async searchSessionHistory(params: SessionSearchRequest) { - while (this.providerUpdate !== null) await this.providerUpdate; - if (this.providerRuntimeControl) throw RequestError.invalidRequest("Runtime control is in progress"); - this.providerReads++; - try { return await searchSessions(this.codexAcpClient.appServerClient, params); } - finally { this.providerReads--; } - } - - async manageSessionAttachments(params: SessionAttachmentsRequest) { - while (this.providerUpdate !== null) await this.providerUpdate; - return this.sessionOperation(params.sessionId, async () => { - const state = this.getSessionState(params.sessionId); - if (this.sessionIsClosing(params.sessionId)) throw RequestError.invalidRequest("Session is closing"); - this.runtimeControls.add(params.sessionId); - try { return await sessionAttachments(this.codexAcpClient.appServerClient, state.sessionId, params); } - finally { this.runtimeControls.delete(params.sessionId); } - }); - } - - async readSessionRuntime(request: RuntimeReadRequest, signal: AbortSignal = new AbortController().signal): Promise { - request = runtimeReadParser.parse(request); - while (this.providerUpdate !== null) await this.providerUpdate; - return this.sessionOperation(request.sessionId, async () => { - const state = this.getSessionState(request.sessionId); - const client = this.codexAcpClient; - const generation = this.getSessionGeneration(request.sessionId); - return readRuntime(client, state, request, signal, () => - this.codexAcpClient === client && this.sessionPublishIsCurrent(state, generation)); - }); - } - - async controlSessionRuntime(request: RuntimeControlRequest): Promise { - request = runtimeControlParser.parse(request); - while (this.providerUpdate !== null) await this.providerUpdate; - this.assertNotRewinding(request.sessionId); - const state = this.getSessionState(request.sessionId); - if (request.action === "reconnectMcp" || request.action === "reloadPlugins") { - if (this.providerCreationReservations || this.rewinds.size || this.sessionOperations.size || this.providerReads || this.activePrompts.size - || this.pendingTurnStarts.size || this.closingSessions.size) { - throw RequestError.invalidRequest("Provider is busy; settle its sessions before reconnecting MCP"); - } - this.providerRuntimeControl = true; - try { return await this.boundedRuntimeControl(() => this.runWithProcessCheck(() => controlRuntime(this.codexAcpClient, state, request))); } - finally { this.providerRuntimeControl = false; } - } - if (this.sessionOperations.has(request.sessionId) || this.sessionIsClosing(request.sessionId)) { - throw RequestError.invalidRequest("Session lifecycle or settings update is in progress"); - } - return this.sessionOperation(request.sessionId, async () => { - this.runtimeControls.add(request.sessionId); - try { - return await this.boundedRuntimeControl(async () => { - const result = await this.runWithProcessCheck(() => controlRuntime(this.codexAcpClient, state, request)); - this.assertProviderAvailable(); - await this.publishAvailableCommands(state, this.getSessionGeneration(request.sessionId)); - return result; - }); - } finally { this.runtimeControls.delete(request.sessionId); } - }); - } - - async archiveSession(sessionId: string, archived: boolean): Promise<{sessionId: string; archived: boolean}> { - while (this.providerUpdate !== null) await this.providerUpdate; - this.assertNotRewinding(sessionId); - if (this.sessionOperations.has(sessionId) || this.activePrompts.has(sessionId) || this.pendingTurnStarts.has(sessionId) - || this.sessionIsClosing(sessionId)) throw RequestError.invalidRequest("Session is busy; settle work before archiving"); - // Hold a named fence until native archive/unarchive completes. closeSession is cleanup, - // not a writer-lock handoff: the same app-server performs the archive. - return this.sessionOperation(sessionId, async () => { - this.beginSessionCloseFence(sessionId); - try { - if (this.hasLocalSession(sessionId) && this.sessions.has(sessionId)) await this.closeSessionDuringOperation({sessionId}); - await this.runWithProcessCheck(() => archived - ? this.codexAcpClient.archiveSession(sessionId) - : this.codexAcpClient.unarchiveSession(sessionId)); - this.rewindRecoveryRequired.delete(sessionId); - return {sessionId, archived}; - } finally { this.endSessionCloseFence(sessionId); } - }); - } - async listHooks(cwd: string): Promise { if (this.providerUpdate !== null) await this.providerUpdate; return await this.runWithProcessCheck(() => listCodexHooks(this.codexAcpClient.appServerClient, cwd)); @@ -1301,7 +1101,6 @@ export class CodexAcpServer { } private installSessionState(sessionState: SessionState): void { - this.assertProviderAvailable(); this.sessions.get(sessionState.sessionId)?.asyncTasks.clear(); this.sessions.set(sessionState.sessionId, sessionState); } @@ -1322,7 +1121,6 @@ export class CodexAcpServer { } private async loadSessionDuringOperation(params: acp.LoadSessionRequest): Promise { - if (this.codexAcpClient.queueSupport.actions.includes("list")) await this.assertNoPendingNativeQueue(params.sessionId); if (this.providerUpdate !== null) { await this.providerUpdate; } @@ -1378,7 +1176,6 @@ export class CodexAcpServer { } private async resumeSessionDuringOperation(params: acp.ResumeSessionRequest): Promise { - if (this.codexAcpClient.queueSupport.actions.includes("list")) await this.assertNoPendingNativeQueue(params.sessionId); if (this.providerUpdate !== null) { await this.providerUpdate; } @@ -1444,7 +1241,6 @@ export class CodexAcpServer { } async closeSession(params: acp.CloseSessionRequest): Promise { - if (this.providerInvalidated) return {}; return this.sessionOperation(params.sessionId, async () => { const result = await this.closeSessionDuringOperation(params); return result; @@ -1534,13 +1330,9 @@ export class CodexAcpServer { async newSession( params: acp.NewSessionRequest, ): Promise { - this.assertProviderAvailable(); - if (this.providerRuntimeControl) throw RequestError.invalidRequest("Runtime control is in progress"); - this.providerCreationReservations++; - try { - while (this.providerUpdate !== null) await this.providerUpdate; - this.assertProviderAvailable(); - if (this.providerRuntimeControl) throw RequestError.invalidRequest("Runtime control is in progress"); + if (this.providerUpdate !== null) { + await this.providerUpdate; + } logger.log("Starting new session..."); const [sessionId, modelState, modeState] = await this.getOrCreateSession(params); @@ -1556,15 +1348,13 @@ export class CodexAcpServer { modes: modeState, ...this.createSessionConfigOptionsResponse(this.getSessionState(sessionId)), }; - } finally { this.providerCreationReservations--; } } async authenticate( _params: acp.AuthenticateRequest, requestId?: acp.JsonRpcId, ): Promise { - this.assertProviderAvailable(); - if (this.providerCreationReservations || this.providerRuntimeControl || this.rewinds.size > 0) throw RequestError.invalidRequest("Provider history or runtime operation is in progress"); + if (this.rewinds.size > 0) throw RequestError.invalidRequest("Session rewind is in progress"); logger.log("Authenticate request received"); const elicitationRequester = this.createUrlElicitationRequester(requestId); const isAuthenticated = await this.runWithProcessCheck(() => this.codexAcpClient.authenticate(_params, elicitationRequester)); @@ -1603,8 +1393,7 @@ export class CodexAcpServer { } async logout(_params: acp.LogoutRequest): Promise { - this.assertProviderAvailable(); - if (this.providerCreationReservations || this.providerRuntimeControl || this.rewinds.size > 0) throw RequestError.invalidRequest("Provider history or runtime operation is in progress"); + if (this.rewinds.size > 0) throw RequestError.invalidRequest("Session rewind is in progress"); logger.log("Logout request received"); await this.runWithProcessCheck(() => this.codexAcpClient.logout()); await this.refreshAuthState(null); @@ -1616,22 +1405,16 @@ export class CodexAcpServer { } async setProvider(params: acp.SetProviderRequest): Promise { - this.assertProviderAvailable(); - if (this.providerRuntimeControl || this.providerCreationReservations) throw RequestError.invalidRequest("Runtime control or session creation is in progress"); while (this.rewinds.size > 0) await Promise.all([...this.rewinds.values()]); - if (this.providerRuntimeControl) throw RequestError.invalidRequest("Runtime control is in progress"); - if (this.providerCreationReservations || this.sessionOperations.size > 0 || this.providerReads > 0) throw RequestError.invalidRequest("A session lifecycle or settings operation is in progress"); + if (this.sessionOperations.size > 0) throw RequestError.invalidRequest("A session lifecycle or settings operation is in progress"); this.codexAcpClient.setProvider(params); await this.enqueueProviderUpdate((client) => client.setProvider(params)); return { }; } async disableProvider(params: acp.DisableProviderRequest): Promise { - this.assertProviderAvailable(); - if (this.providerRuntimeControl || this.providerCreationReservations) throw RequestError.invalidRequest("Runtime control or session creation is in progress"); while (this.rewinds.size > 0) await Promise.all([...this.rewinds.values()]); - if (this.providerRuntimeControl) throw RequestError.invalidRequest("Runtime control is in progress"); - if (this.providerCreationReservations || this.sessionOperations.size > 0 || this.providerReads > 0) throw RequestError.invalidRequest("A session lifecycle or settings operation is in progress"); + if (this.sessionOperations.size > 0) throw RequestError.invalidRequest("A session lifecycle or settings operation is in progress"); this.codexAcpClient.disableProvider(params); if (params.providerId !== OPENAI_PROVIDER_ID) { return { }; @@ -1671,10 +1454,6 @@ export class CodexAcpServer { for (const session of this.sessions.values()) { session.asyncTasks.setAppServer(replacement.appServerClient); try { - if (replacement.queueSupport.actions.includes("list")) { - const pending = await replacement.appServerClient.queueNative().list({threadId: session.sessionId, limit: 1}); - if (pending.data.length || pending.nextCursor) throw RequestError.invalidRequest("Native pending queue cannot be auto-resumed by ACP"); - } await replacement.resumeSession({ sessionId: session.sessionId, cwd: session.cwd, @@ -2200,7 +1979,6 @@ export class CodexAcpServer { return {outcome: "injected"}; } } - if (params._meta?.steering?.idleBehavior === "promptRequired") return {outcome: "promptRequired"}; return await this.startNewTurnFromSteering(params); } @@ -2381,14 +2159,9 @@ export class CodexAcpServer { if (typeof sessionId !== "string" || !Array.isArray(prompt)) { throw RequestError.invalidParams(); } - const meta = params["_meta"]; - const steering = typeof meta === "object" && meta !== null && "steering" in meta ? meta.steering : undefined; - const idleBehavior = typeof steering === "object" && steering !== null && "idleBehavior" in steering ? steering.idleBehavior : undefined; - if (idleBehavior !== undefined && idleBehavior !== "promptRequired") throw RequestError.invalidParams(undefined, "Unknown steering idle behavior"); return { - sessionId, + sessionId: sessionId, prompt: prompt as acp.ContentBlock[], - ...(idleBehavior === "promptRequired" ? {_meta: {steering: {idleBehavior}}} : {}), }; } @@ -2489,7 +2262,7 @@ export class CodexAcpServer { } private sessionPublishIsCurrent(sessionState: SessionState, sessionGeneration: number): boolean { - return !this.providerInvalidated && this.sessions.get(sessionState.sessionId) === sessionState + return this.sessions.get(sessionState.sessionId) === sessionState && this.getSessionGeneration(sessionState.sessionId) === sessionGeneration && !this.sessionIsClosing(sessionState.sessionId); } @@ -3891,7 +3664,6 @@ export class CodexAcpServer { } private async runWithProcessCheck(operation: () => Promise): Promise { - this.assertProviderAvailable(); try { return await operation(); } catch (err) { diff --git a/src/CodexAppServerClient.ts b/src/CodexAppServerClient.ts index bd56eadec..92bcec634 100644 --- a/src/CodexAppServerClient.ts +++ b/src/CodexAppServerClient.ts @@ -1,7 +1,3 @@ -import type {SessionQueueAction, SessionQueueNative} from "./SessionQueue"; -import type {NativeThreadSearchParams, NativeThreadSearchResponse} from "./SessionDiscovery"; -import type {ThreadAttachmentListParams, ThreadAttachmentListResponse, ThreadAttachmentAddParams, ThreadAttachmentAddResponse, ThreadAttachmentRemoveParams, ThreadAttachmentRemoveResponse} from "./app-server/v2"; -import type {ThreadUnarchiveParams, ThreadUnarchiveResponse, PluginInstalledParams, PluginInstalledResponse, PluginReconcileParams, PluginReconcileResponse} from "./app-server/v2"; import {type MessageConnection, RequestType} from "vscode-jsonrpc/node"; import {McpOauthCompletions} from "./mcp/McpOauthCompletions"; import {McpStartupTracker} from "./mcp/McpStartupTracker"; @@ -735,49 +731,6 @@ export class CodexAppServerClient { } } - /** Invalid parameters only: proves method registration without a valid mutation target. */ - async probeQueueAction(action: SessionQueueAction): Promise { - try { await this.connection.sendRequest(`thread/queue/${action}`, {}); return false; } - catch (error) { - return typeof error === "object" && error !== null && "code" in error && error.code === -32600 - && "message" in error && error.message === "Invalid request: missing field `threadId`"; - } - } - - queueNative(): SessionQueueNative { - return { - list: params => this.connection.sendRequest("thread/queue/list", params), - add: params => this.connection.sendRequest("thread/queue/add", params), - update: params => this.connection.sendRequest("thread/queue/update", params), - delete: params => this.connection.sendRequest("thread/queue/delete", params), - reorder: params => this.connection.sendRequest("thread/queue/reorder", params), - start: params => this.connection.sendRequest("thread/queue/start", params), - }; - } - - async threadSearch(params: NativeThreadSearchParams): Promise { - return this.connection.sendRequest("thread/search", {...params, sourceKinds: ["appServer", "cli", "vscode"]}); - } - async threadAttachmentList(params: ThreadAttachmentListParams): Promise { - return this.sendRequest({method: "thread/attachment/list", params}); - } - async threadAttachmentAdd(params: ThreadAttachmentAddParams): Promise { - return this.sendRequest({method: "thread/attachment/add", params}); - } - async threadAttachmentRemove(params: ThreadAttachmentRemoveParams): Promise { - return this.sendRequest({method: "thread/attachment/remove", params}); - } - - async threadUnarchive(params: ThreadUnarchiveParams): Promise { - return this.sendRequest({method: "thread/unarchive", params}); - } - async pluginInstalled(params: PluginInstalledParams): Promise { - return this.sendRequest({method: "plugin/installed", params}); - } - async pluginReconcile(params: PluginReconcileParams): Promise { - return this.sendRequest({method: "plugin/reconcile", params}); - } - async threadArchive(params: ThreadArchiveParams): Promise { return await this.sendRequest({ method: "thread/archive", params: params }); } diff --git a/src/CodexJsonRpcConnection.ts b/src/CodexJsonRpcConnection.ts index e7b98f65e..207f075d9 100644 --- a/src/CodexJsonRpcConnection.ts +++ b/src/CodexJsonRpcConnection.ts @@ -66,26 +66,3 @@ function attachLogs(proc: ChildProcessWithoutNullStreams) { logger.log(`[EXIT] code: ${code?.toString()}`); }); } - -/** Stops an unresponsive owned provider before allowing the client to reconnect. */ -export async function terminateCodexConnection(native: CodexConnection): Promise { - native.connection.dispose(); - const child = native.process; - if (child.exitCode !== null) return; - const exited = new Promise(resolve => child.once("exit", () => resolve())); - if (process.platform === "win32" && child.pid !== undefined) { - // The bundled launcher is Node with a native child; kill the owned tree, not only Node. - const killer = spawn("taskkill", ["/PID", String(child.pid), "/T", "/F"], {windowsHide: true, stdio: "ignore"}); - await new Promise((resolve, reject) => { - const timer = setTimeout(() => { killer.kill(); reject(new Error("Provider tree termination timed out")); }, 5_000); - killer.once("error", error => {clearTimeout(timer); reject(error);}); - killer.once("exit", code => {clearTimeout(timer); code === 0 || child.exitCode !== null ? resolve() : reject(new Error("Provider tree termination failed"));}); - }); - } else { - child.kill("SIGKILL"); - } - let timer: ReturnType | undefined; - try { - await Promise.race([exited, new Promise((_, reject) => {timer = setTimeout(() => reject(new Error("Provider did not exit")), 5_000);})]); - } finally {if (timer) clearTimeout(timer);} -} diff --git a/src/DesktopAttachmentHistory.ts b/src/DesktopAttachmentHistory.ts index dbd093b49..932ccd473 100644 --- a/src/DesktopAttachmentHistory.ts +++ b/src/DesktopAttachmentHistory.ts @@ -20,7 +20,7 @@ export function attachmentFileUri(path: string): string | null { return null; } } - return path.startsWith("/") ? pathToFileURL(path, {windows: false}).href : null; + return path.startsWith("/") ? pathToFileURL(path).href : null; } /** Converts the known Desktop attachment envelope into history content blocks. */ diff --git a/src/SessionArchive.ts b/src/SessionArchive.ts deleted file mode 100644 index 0884b377f..000000000 --- a/src/SessionArchive.ts +++ /dev/null @@ -1,7 +0,0 @@ -import {z} from "zod"; -export const SESSION_ARCHIVE_METHOD = "_session/archive"; -export const SESSION_UNARCHIVE_METHOD = "_session/unarchive"; -export const sessionArchiveParser = z.object({sessionId: z.string().trim().min(1)}).strict(); -export function archiveCapability() { - return {version: 1, archiveMethod: SESSION_ARCHIVE_METHOD, unarchiveMethod: SESSION_UNARCHIVE_METHOD, permanentDelete: false}; -} diff --git a/src/SessionDiscovery.ts b/src/SessionDiscovery.ts deleted file mode 100644 index c4c1f2b71..000000000 --- a/src/SessionDiscovery.ts +++ /dev/null @@ -1,73 +0,0 @@ -import {RequestError} from "@agentclientprotocol/sdk"; -import {z} from "zod"; -import type {Thread, ThreadAttachmentAddParams, ThreadAttachmentAddResponse, ThreadAttachmentListParams, ThreadAttachmentListResponse, ThreadAttachmentRemoveParams, ThreadAttachmentRemoveResponse} from "./app-server/v2"; - -export const SESSION_SEARCH_METHOD = "_session/search"; -export const SESSION_ATTACHMENTS_METHOD = "_session/attachments"; -const identifier = z.string().min(1).max(4096).refine(value => value.trim().length > 0 && !Array.from(value).some(char => char.charCodeAt(0) < 32)); -const page = {cursor: identifier.nullable().optional(), limit: z.number().int().min(1).max(100).optional()}; -export const sessionSearchParser = z.object({ - searchTerm: z.string().trim().min(1).max(4096), - archived: z.boolean().optional(), - ...page, -}).strict(); -export type SessionSearchRequest = z.infer; -// Typed experimental method omitted from the package's generated subset. Shape -// verified against native 0.160.1 and the public app-server protocol source. -export type NativeThreadSearchParams = SessionSearchRequest; -export type NativeThreadSearchResponse = {data: Array<{thread: Thread, snippet: string}>, nextCursor: string | null, backwardsCursor?: string | null}; - -const attachmentIdentity = {sessionId: identifier, attachmentType: identifier, identityKey: identifier}; -const boundedJson = z.json().refine(value => Buffer.byteLength(JSON.stringify(value), "utf8") <= 256 * 1024, "Attachment payload exceeds 256 KiB"); -export const sessionAttachmentsParser = z.discriminatedUnion("action", [ - z.object({sessionId: identifier, action: z.literal("list"), ...page}).strict(), - z.object({...attachmentIdentity, action: z.literal("add"), payload: boundedJson}).strict(), - z.object({...attachmentIdentity, action: z.literal("remove")}).strict(), -]); -export type SessionAttachmentsRequest = z.infer; -export type SessionDiscoveryClient = { - threadSearch(params: NativeThreadSearchParams): Promise; - threadAttachmentList(params: ThreadAttachmentListParams): Promise; - threadAttachmentAdd(params: ThreadAttachmentAddParams): Promise; - threadAttachmentRemove(params: ThreadAttachmentRemoveParams): Promise; -}; - -export function sessionDiscoveryCapability() { - return { - version: 1, - searchMethod: SESSION_SEARCH_METHOD, - attachmentMethod: SESSION_ATTACHMENTS_METHOD, - attachmentActions: ["list", "add", "remove"], - attachmentsAreMetadata: true, - }; -} - -/** Search is native text search, not a model prompt or a local scan of files. */ -export async function searchSessions(client: Pick, params: SessionSearchRequest) { - const response = await client.threadSearch(sessionSearchParser.parse(params)); - return { - version: 1, - sessions: response.data.map(({thread, snippet}) => ({ - sessionId: thread.id, cwd: thread.cwd, title: thread.name ?? thread.preview, - updatedAt: thread.updatedAt, snippet, - })), - nextCursor: response.nextCursor, - }; -} - -/** These are independently persisted native thread attachments (PR links, - * worktree associations, etc.), not prompt images or disk operations. The host - * remains responsible for actually creating/removing any associated resource. */ -export async function sessionAttachments( - client: Omit, - sessionId: string, - params: SessionAttachmentsRequest, -): Promise<{version: 1, data: unknown}> { - params = sessionAttachmentsParser.parse(params); - if (params.sessionId !== sessionId) throw RequestError.invalidParams(undefined, "Session mismatch"); - switch (params.action) { - case "list": return {version: 1, data: await client.threadAttachmentList({threadId: sessionId, ...(params.cursor === undefined ? {} : {cursor: params.cursor}), ...(params.limit === undefined ? {} : {limit: params.limit})})}; - case "add": return {version: 1, data: await client.threadAttachmentAdd({threadId: sessionId, attachmentType: params.attachmentType, identityKey: params.identityKey, payload: params.payload})}; - case "remove": return {version: 1, data: await client.threadAttachmentRemove({threadId: sessionId, attachmentType: params.attachmentType, identityKey: params.identityKey})}; - } -} diff --git a/src/SessionFileRevert.ts b/src/SessionFileRevert.ts deleted file mode 100644 index 5ba556d2a..000000000 --- a/src/SessionFileRevert.ts +++ /dev/null @@ -1,300 +0,0 @@ -import { spawn } from "node:child_process"; -import { createHash } from "node:crypto"; -import { lstat, readFile, realpath } from "node:fs/promises"; -import path from "node:path"; -import { RequestError } from "@agentclientprotocol/sdk"; -import { z } from "zod"; -import type { Thread, ThreadItem } from "./app-server/v2"; -import { - createAddedFileGitPatch, - createDeletedFileGitPatch, - createUpdateGitPatch, -} from "./GitPatch"; - -export const SESSION_FILE_REVERT_METHOD = "_session/files/revert"; -export const sessionFileRevertParser = z - .object({ - sessionId: z.string().trim().min(1), - toolCallId: z.string().trim().min(1), - dryRun: z.boolean(), - previewToken: z - .string() - .regex(/^sha256:[a-f0-9]{64}$/) - .optional(), - }) - .strict() - .refine( - (p) => p.dryRun || p.previewToken !== undefined, - "A restore requires its preview token", - ); -export type SessionFileRevertRequest = z.infer; -type NativeFileChange = Extract; -export type FileRevertSession = { - sessionId: string; - cwd: string; - additionalDirectories: string[]; -}; -export type FileRevertResult = { - version: 1; - dryRun: boolean; - canRevert: boolean; - reverted: boolean; - paths: string[]; - previewToken?: string; - reason?: string; -}; -export function fileRevertCapability() { - return { - version: 1, - method: SESSION_FILE_REVERT_METHOD, - dryRun: true, - previewTokenRequired: true, - scope: "nativeFileChangeTool", - requiresGit: true, - changesConversation: false, - coverage: - "Recorded text patches only; not shell changes or a whole-workspace checkpoint", - }; -} - -const hash = (value: string | Buffer) => - createHash("sha256").update(value).digest("hex"); -const MAX_BYTES = 8 * 1024 * 1024; -function inside(root: string, candidate: string): boolean { - const relative = path.relative(root, candidate); - return ( - relative !== "" && - !path.isAbsolute(relative) && - relative !== ".." && - !relative.startsWith(`..${path.sep}`) - ); -} - -/** Ignore inherited alternate-index/worktree knobs. This operation never stages - * files and never changes HEAD, branches, hooks, or the user's Git index. */ -async function git( - cwd: string, - args: string[], - input?: string, -): Promise<{ code: number | null; stdout: string }> { - const env = Object.fromEntries( - Object.entries(process.env).filter( - ([key]) => !key.toUpperCase().startsWith("GIT_"), - ), - ); - return new Promise((resolve, reject) => { - const child = spawn("git", args, { - cwd, - env, - windowsHide: true, - stdio: ["pipe", "pipe", "pipe"], - }); - const chunks: Buffer[] = []; - let size = 0; - const timer = setTimeout(() => child.kill(), 15_000); - child.stdout.on("data", (chunk: Buffer) => { - size += chunk.length; - if (size <= MAX_BYTES) chunks.push(chunk); - else child.kill(); - }); - child.stderr.resume(); - child.stdin.on("error", () => {}); - child.once("error", (error) => { - clearTimeout(timer); - reject(error); - }); - child.once("close", (code) => { - clearTimeout(timer); - resolve({ code, stdout: Buffer.concat(chunks).toString("utf8") }); - }); - child.stdin.end(input); - }); -} - -async function checkedPath( - root: string, - roots: string[], - value: string, -): Promise<{ absolute: string; relative: string }> { - const absolute = path.resolve(value); - if ( - !inside(root, absolute) || - !roots.some((workspace) => inside(workspace, absolute)) - ) - throw new Error("outside_workspace"); - const relative = path.relative(root, absolute); - const parts = relative.split(path.sep); - if (parts.some((part) => part.toLowerCase() === ".git" || part.includes(":"))) - throw new Error("unsafe_path"); - let walk = root; - for (let i = 0; i < parts.length; i++) { - walk = path.join(walk, parts[i]!); - try { - const stat = await lstat(walk); - if ( - stat.isSymbolicLink() || - (i < parts.length - 1 ? !stat.isDirectory() : !stat.isFile()) - ) - throw new Error("unsupported_file_type"); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") break; - throw error; - } - } - return { absolute, relative: relative.split(path.sep).join("/") }; -} - -async function fingerprintFiles(files: string[]): Promise { - return Promise.all( - files.map(async (file) => { - try { - const stat = await lstat(file); - if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_BYTES) - throw new Error("unsupported_file_type_or_size"); - return `${file}\0${stat.mode}\0${hash(await readFile(file))}`; - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") - return `${file}\0missing`; - throw error; - } - }), - ); -} - -/** Hold the session AND overlapping-workspace mutation fence around this whole - * call. External editors are not controlled by the adapter; Git rejects patch - * conflicts, but this is not a cross-process filesystem transaction. */ -export async function revertSessionFiles( - value: unknown, - session: FileRevertSession, - readHistory: () => Promise<{ thread: Thread }>, -): Promise { - const request = sessionFileRevertParser.parse(value); - if (request.sessionId !== session.sessionId) - throw RequestError.invalidParams(undefined, "Session mismatch"); - const base: FileRevertResult = { - version: 1, - dryRun: request.dryRun, - canRevert: false, - reverted: false, - paths: [], - }; - const { thread } = await readHistory(); - if (thread.id !== session.sessionId) - throw RequestError.invalidParams(undefined, "History session mismatch"); - const matches = thread.turns - .flatMap((turn) => turn.items) - .filter( - (item): item is NativeFileChange => - item.type === "fileChange" && item.id === request.toolCallId, - ); - if (matches.length !== 1 || matches[0]!.status !== "completed") - return { ...base, reason: "completed_patch_not_found" }; - const changes = matches[0]!.changes; - if (!Array.isArray(changes) || changes.length === 0 || changes.length > 64) - return {...base, reason: "unsupported_patch_size"}; - // Validate every native change before constructing any patch. Generated types cannot - // validate runtime JSON from an older/newer native process. - for (const change of changes as unknown[]) { - if (change === null || typeof change !== "object") return {...base, reason: "unsupported_patch"}; - const value = change as {path?: unknown; kind?: {type?: unknown; move_path?: unknown}; diff?: unknown}; - if (typeof value.path !== "string" || !value.path || typeof value.diff !== "string" - || value.kind === null || typeof value.kind !== "object" - || !["add", "delete", "update"].includes(String(value.kind.type)) - || (value.kind.type === "update" && value.kind.move_path != null && typeof value.kind.move_path !== "string")) { - return {...base, reason: "unsupported_patch"}; - } - } - const found = await git(session.cwd, ["rev-parse", "--show-toplevel"]); - if (found.code !== 0) return { ...base, reason: "not_git_repository" }; - const root = await realpath(found.stdout.trim()); - const roots = await Promise.all( - [session.cwd, ...session.additionalDirectories].map((value) => - realpath(value), - ), - ); - let patch = ""; - const paths = new Set(); - try { - for (const change of changes) { - const source = await checkedPath( - root, - roots, - path.resolve(session.cwd, change.path), - ); - paths.add(source.absolute); - let part: string | null; - switch (change.kind.type) { - case "add": - part = createAddedFileGitPatch(source.relative, change.diff); - break; - case "delete": - part = createDeletedFileGitPatch(source.relative, change.diff); - break; - case "update": { - const target = change.kind.move_path - ? await checkedPath( - root, - roots, - path.resolve(session.cwd, change.kind.move_path), - ) - : source; - paths.add(target.absolute); - part = createUpdateGitPatch( - source.relative, - target.relative, - change.diff, - ); - break; - } - default: - return { ...base, reason: "unsupported_patch" }; - } - if (part === null) return { ...base, reason: "unsupported_patch" }; - patch += part; - if (Buffer.byteLength(patch, "utf8") > MAX_BYTES) - return { ...base, reason: "unsupported_patch_size" }; - } - base.paths = [...paths].sort(); - const before = await fingerprintFiles(base.paths); - const previewToken = `sha256:${hash(JSON.stringify([root, session.sessionId, request.toolCallId, patch, before]))}`; - if (!request.dryRun && request.previewToken !== previewToken) - return { ...base, reason: "stale_preview" }; - const check = await git( - root, - ["apply", "--reverse", "--check", "--whitespace=nowarn", "-"], - patch, - ); - if (check.code !== 0) return { ...base, reason: "patch_conflict" }; - if (request.dryRun) return { ...base, canRevert: true, previewToken }; - // Validate nodes again immediately before applying, including parents. - for (const file of base.paths) await checkedPath(root, roots, file); - if ( - JSON.stringify(before) !== - JSON.stringify(await fingerprintFiles(base.paths)) - ) - return { ...base, reason: "stale_preview" }; - const applied = await git( - root, - ["apply", "--reverse", "--whitespace=nowarn", "-"], - patch, - ); - if (applied.code !== 0) - return { ...base, reason: "restore_outcome_unknown" }; - return { ...base, canRevert: true, reverted: true }; - } catch (error) { - // Never include file contents or provider stderr in a failure response. - if ( - error instanceof Error && - [ - "outside_workspace", - "unsafe_path", - "unsupported_file_type", - "unsupported_file_type_or_size", - ].includes(error.message) - ) { - return { ...base, reason: error.message }; - } - throw error; - } -} diff --git a/src/SessionQueue.ts b/src/SessionQueue.ts deleted file mode 100644 index dd0de086f..000000000 --- a/src/SessionQueue.ts +++ /dev/null @@ -1,191 +0,0 @@ -import {RequestError} from "@agentclientprotocol/sdk"; -import {z} from "zod"; -import type {Turn} from "./app-server/v2/Turn"; - -export const SESSION_QUEUE_METHOD = "_session/queue"; -export const SESSION_QUEUE_ACTIONS = ["list", "add", "update", "delete", "reorder", "start"] as const; -export type SessionQueueAction = typeof SESSION_QUEUE_ACTIONS[number]; - -// A conservative tested floor, NOT a claim about the first native release. -// Source: codex-rs/app-server-protocol/src/protocol/v2/thread.rs:926-1027; -// experimental registrations: protocol/common.rs:630-664. Not generated types. -export const SESSION_QUEUE_MIN_NATIVE_VERSION = "0.160.1"; - -const identifier = z.string().refine(value => value.trim().length > 0 && !/[\u0000-\u001f\u007f]/u.test(value)); -const byteOffset = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER); -const textElement = z.strictObject({ - byteRange: z.strictObject({start: byteOffset, end: byteOffset}), - placeholder: z.string().nullable().optional(), -}); -const detail = z.enum(["auto", "low", "high", "original"]).nullable().optional(); - -// Preserve native UserInput, including snake_case text_elements and omitted -// serde-default fields. No ACP text/image conversion, defaults, or dropped keys. -// Source: protocol/v2/turn.rs:382-474. New variants require an explicit review. -const nativeInput = z.union([ - z.strictObject({type: z.literal("text"), text: z.string(), text_elements: z.array(textElement).optional()}), - z.strictObject({type: z.literal("image"), url: z.string(), detail}), - z.strictObject({type: z.literal("image"), fileId: z.string(), detail}), - z.strictObject({type: z.literal("localImage"), path: z.string(), detail}), - z.strictObject({type: z.literal("audio"), url: z.string()}), - z.strictObject({type: z.literal("localAudio"), path: z.string()}), - z.strictObject({type: z.literal("skill"), name: z.string(), path: z.string()}), - z.strictObject({type: z.literal("mention"), name: z.string(), path: z.string()}), -]); -export type NativeQueueInput = z.infer; - -const session = {sessionId: identifier}; -const requestSchema = z.discriminatedUnion("action", [ - z.strictObject({ - ...session, action: z.literal("list"), cursor: z.string().nullable().optional(), - limit: z.number().int().min(0).max(0xffff_ffff).nullable().optional(), - }), - z.strictObject({ - ...session, action: z.literal("add"), input: z.array(nativeInput), clientUserMessageId: identifier, - }), - z.strictObject({ - ...session, action: z.literal("update"), queuedSubmissionId: identifier, input: z.array(nativeInput), - }), - z.strictObject({...session, action: z.literal("delete"), queuedSubmissionId: identifier}), - z.strictObject({...session, action: z.literal("reorder"), queuedSubmissionIds: z.array(identifier)}), - z.strictObject({...session, action: z.literal("start"), queuedSubmissionId: identifier.nullable().optional()}), -]); -export type SessionQueueRequest = z.infer; - -export function parseSessionQueueRequest(value: unknown): SessionQueueRequest { - const parsed = requestSchema.safeParse(value); - if (!parsed.success) { - // Do not echo prompt content, attachment URLs, or unknown injected fields. - throw RequestError.invalidParams(undefined, "Invalid session queue request"); - } - return parsed.data; -} - -export type NativeQueuedSubmission = { - id: string; - input: NativeQueueInput[]; - clientUserMessageId: string; - [key: string]: unknown; -}; -export type NativeQueueListResponse = {data: NativeQueuedSubmission[]; nextCursor: string | null; [key: string]: unknown}; -export type NativeQueueWriteResponse = {queuedSubmission: NativeQueuedSubmission; [key: string]: unknown}; -export type NativeQueueDeleteResponse = {deleted: boolean; [key: string]: unknown}; -export type NativeQueueReorderResponse = Record; -export type NativeQueueStartResponse = {turn: Turn; [key: string]: unknown}; - -type NativeParams = - Omit, "sessionId" | "action"> & {threadId: string}; - -/** No caller-selectable RPC method or free-form native params escape hatch. */ -export interface SessionQueueNative { - list(params: NativeParams<"list">): Promise; - add(params: NativeParams<"add">): Promise; - update(params: NativeParams<"update">): Promise; - delete(params: NativeParams<"delete">): Promise; - reorder(params: NativeParams<"reorder">): Promise; - /** This starts real generation. The owning server must attach turn handlers first. */ - start(params: NativeParams<"start">): Promise; -} - -export type SessionQueueSupport = { - readonly nativeVersion: string | null; - readonly actions: readonly SessionQueueAction[]; -}; - -function eligibleNativeVersion(version: string | null | undefined): boolean { - // Only an actual normalized stable native version. An npm package version, - // missing version, or unverified prerelease is not support evidence. - const match = version?.match(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/u); - if (!match) return false; - const [major, minor, patch] = match.slice(1).map(Number); - if (![major, minor, patch].every(value => Number.isSafeInteger(value))) return false; - return major! > 0 || minor! > 160 || (minor === 160 && patch! >= 1); -} - -/** - * The integrator supplies per-action POSITIVE evidence from the running native - * process (e.g. its actual experimental schema, or a side-effect-free parameter - * validation probe). A list-only probe proves only list. Never call a valid - * add/update/delete/reorder/start to probe, and never treat an arbitrary -32602, - * auth/store/network error, or package version as proof of method support. - * Cache only for that native process generation; re-probe on restart/override. - */ -export async function probeSessionQueueSupport( - nativeVersion: string | null | undefined, - probeAction: (action: SessionQueueAction) => Promise, -): Promise { - const actions: SessionQueueAction[] = []; - if (eligibleNativeVersion(nativeVersion)) { - for (const action of SESSION_QUEUE_ACTIONS) { - try { - if (await probeAction(action) === true) actions.push(action); - } catch { - // Absence of affirmative evidence leaves this action unadvertised. - } - } - } - return Object.freeze({nativeVersion: nativeVersion ?? null, actions: Object.freeze(actions)}); -} - -export function sessionQueueCapability(support: SessionQueueSupport) { - if (!eligibleNativeVersion(support.nativeVersion)) return undefined; - const actions = SESSION_QUEUE_ACTIONS.filter(action => support.actions.includes(action)); - if (actions.length === 0) return undefined; - return {version: 1 as const, method: SESSION_QUEUE_METHOD, actions}; -} - -export type SessionQueueResponse = - | {status: "unsupported"} - | {status: "ok"; result: NativeQueueListResponse | NativeQueueWriteResponse | - NativeQueueDeleteResponse | NativeQueueReorderResponse | NativeQueueStartResponse}; - -/** - * Must run INSIDE the server's lifecycle reservation for this exact session and - * native process: serialize with prompt, rewind, close, delete, provider change, - * and queue operations until the native promise settles. A timeout/transport - * loss does not imply no mutation; the caller must reconcile, never auto-retry. - * This module has no transport, process launching, locks, or event ownership. - */ -export async function runSessionQueue( - value: unknown, - dependencies: {sessionId: string; support: SessionQueueSupport; native: SessionQueueNative}, -): Promise { - const request = parseSessionQueueRequest(value); - if (request.sessionId !== dependencies.sessionId) { - throw RequestError.invalidParams(undefined, "Queue request does not target the bound session"); - } - const capability = sessionQueueCapability(dependencies.support); - if (!capability?.actions.includes(request.action)) return {status: "unsupported"}; - const threadId = dependencies.sessionId; - const native = dependencies.native; - try { - switch (request.action) { - case "list": - return {status: "ok", result: await native.list({ - threadId, - ...("cursor" in request ? {cursor: request.cursor} : {}), - ...("limit" in request ? {limit: request.limit} : {}), - })}; - case "add": - return {status: "ok", result: await native.add({threadId, input: request.input, clientUserMessageId: request.clientUserMessageId})}; - case "update": - return {status: "ok", result: await native.update({threadId, queuedSubmissionId: request.queuedSubmissionId, input: request.input})}; - case "delete": - return {status: "ok", result: await native.delete({threadId, queuedSubmissionId: request.queuedSubmissionId})}; - case "reorder": - return {status: "ok", result: await native.reorder({threadId, queuedSubmissionIds: request.queuedSubmissionIds})}; - case "start": - return {status: "ok", result: await native.start({ - threadId, - ...("queuedSubmissionId" in request ? {queuedSubmissionId: request.queuedSubmissionId} : {}), - })}; - } - } catch (error) { - // A definitive JSON-RPC method-not-found is safe to report as unsupported. - // No other error is converted to success/unsupported and nothing retries. - if (typeof error === "object" && error !== null && "code" in error && error.code === -32601) { - return {status: "unsupported"}; - } - throw error; - } -} diff --git a/src/SessionRuntime.ts b/src/SessionRuntime.ts deleted file mode 100644 index 7b0c692f9..000000000 --- a/src/SessionRuntime.ts +++ /dev/null @@ -1,119 +0,0 @@ -import {RequestError} from "@agentclientprotocol/sdk"; -import {z} from "zod"; -import type {CodexAcpClient} from "./CodexAcpClient"; -import type {SessionState} from "./CodexAcpServer"; -import {listMcpServerStatus} from "./mcp/McpServerStatusList"; - -export const RUNTIME_READ_METHOD = "_session/runtime/read"; -export const RUNTIME_CONTROL_METHOD = "_session/runtime/control"; -export const runtimeReadParser = z.object({ - sessionId: z.string().trim().min(1), - resource: z.enum(["context", "usage", "mcp", "commands", "plugins"]), -}).strict(); -export const runtimeControlParser = z.object({ - sessionId: z.string().trim().min(1), - action: z.enum(["reloadSkills", "reconnectMcp", "reloadPlugins"]), -}).strict(); -export type RuntimeReadRequest = z.infer; -export type RuntimeControlRequest = z.infer; -export type RuntimeResponse = - | {version: 1, status: "ok", data: unknown} - | {version: 1, status: "unavailable", reason: "timeout" | "cancelled" | "stale"}; - -export function runtimeCapability() { - return { - version: 1, - readMethod: RUNTIME_READ_METHOD, - controlMethod: RUNTIME_CONTROL_METHOD, - reads: ["context", "usage", "mcp", "commands", "plugins"], - controls: ["reloadSkills", "reconnectMcp", "reloadPlugins"], - }; -} - -export type RuntimeSession = Pick; -type RuntimeClient = Pick; - -/** Do not synthesize prompt categories from aggregate token usage. Null means - * the native runtime has not supplied the measurement, not zero tokens. */ -export async function readRuntime( - client: RuntimeClient, - session: RuntimeSession, - request: RuntimeReadRequest, - signal: AbortSignal, - isCurrent: () => boolean = () => true, - timeoutMs = 5000, -): Promise { - if (request.sessionId !== session.sessionId) throw RequestError.invalidParams(undefined, "Session mismatch"); - if (signal.aborted) return {version: 1, status: "unavailable", reason: "cancelled"}; - if (!isCurrent()) return {version: 1, status: "unavailable", reason: "stale"}; - const operation = async (): Promise => { - switch (request.resource) { - case "context": return { - model: session.currentModelId, - totalTokens: session.lastTokenUsage?.totalTokens ?? null, - maxTokens: session.modelContextWindow, - categories: null, - source: "last_native_usage_event", - }; - case "usage": return {last: session.lastTokenUsage, total: session.totalTokenUsage, rateLimits: session.rateLimits}; - case "plugins": return await client.installedPlugins([session.cwd, ...session.additionalDirectories]); - case "commands": return await client.listSkills({cwds: [session.cwd, ...session.additionalDirectories]}); - case "mcp": { - // The existing paginated reader takes only listMcpServers in practice. - const servers = await listMcpServerStatus(client as CodexAcpClient, session.sessionId); - return servers.map(server => ({ - name: server.name, - status: server.runtimeStatus, - authStatus: server.authStatus, - pluginId: server.pluginId, - serverInfo: server.serverInfo, - toolNames: Object.keys(server.tools), - })); - } - } - }; - let timer: ReturnType | undefined; - let onAbort: (() => void) | undefined; - try { - const result = await Promise.race([ - operation().then(data => ({version: 1, status: "ok", data})), - new Promise(resolve => { - timer = setTimeout(() => resolve({version: 1, status: "unavailable", reason: "timeout"}), timeoutMs); - timer.unref?.(); - }), - new Promise(resolve => { - onAbort = () => resolve({version: 1, status: "unavailable", reason: "cancelled"}); - signal.addEventListener("abort", onAbort, {once: true}); - }), - ]); - return isCurrent() ? result : {version: 1, status: "unavailable", reason: "stale"}; - } finally { - if (timer) clearTimeout(timer); - if (onAbort) signal.removeEventListener("abort", onAbort); - } -} - -/** The server must retain its lifecycle lease until native completion. - * reloadMcpServers is process-wide: use the provider barrier, not just a - * per-session guard, and do not claim the connections are ready from its ack. */ -export async function controlRuntime( - client: RuntimeClient, - session: RuntimeSession, - request: RuntimeControlRequest, -): Promise { - if (request.sessionId !== session.sessionId) throw RequestError.invalidParams(undefined, "Session mismatch"); - switch (request.action) { - case "reloadSkills": return { - version: 1, status: "ok", - data: await client.listSkills({cwds: [session.cwd, ...session.additionalDirectories], forceReload: true}), - }; - case "reloadPlugins": - return {version: 1, status: "ok", data: {...await client.reconcilePlugins(), scope: "provider", runtimeReady: false}}; - case "reconnectMcp": - await client.reloadMcpServers(); - return {version: 1, status: "ok", data: {completed: true, scope: "provider", connectionsReady: false}}; - } -} diff --git a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts index 7ae250778..7fd3ab720 100644 --- a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts +++ b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts @@ -1,4 +1,3 @@ -import path from "node:path"; // noinspection ES6RedundantAwait import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; @@ -499,7 +498,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { }); expect(extraRootsSetSpy).toHaveBeenCalledWith({ - extraRoots: [path.join("/skills/one", ".agents", "skills"), path.join("/skills/two", ".agents", "skills")], + extraRoots: ["/skills/one/.agents/skills", "/skills/two/.agents/skills"], }); expect(extraRootsSetSpy.mock.invocationCallOrder[0]!).toBeLessThan(threadStartSpy.mock.invocationCallOrder[0]!); expect(listSkillsSpy).not.toHaveBeenCalled(); @@ -534,7 +533,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { expect(session.additionalDirectories).toEqual(["/workspace/extra"]); expect(extraRootsSetSpy).toHaveBeenCalledWith({ - extraRoots: [path.join("/workspace/extra", ".agents", "skills")], + extraRoots: ["/workspace/extra/.agents/skills"], }); expect(extraRootsSetSpy.mock.invocationCallOrder[0]!).toBeLessThan(threadStartSpy.mock.invocationCallOrder[0]!); expect(listSkillsSpy).not.toHaveBeenCalled(); @@ -1385,7 +1384,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { }); expect(extraRootsSetSpy).toHaveBeenCalledWith({ - extraRoots: [path.join("/workspace/extra", ".agents", "skills")], + extraRoots: ["/workspace/extra/.agents/skills"], }); expect(listSkillsSpy).not.toHaveBeenCalledWith(expect.objectContaining({forceReload: true})); expect(turnStartSpy.mock.calls[0]![0].sandboxPolicy).toMatchObject({ diff --git a/src/__tests__/CodexACPAgent/initialize.test.ts b/src/__tests__/CodexACPAgent/initialize.test.ts index 7574fb024..ac82d3ba0 100644 --- a/src/__tests__/CodexACPAgent/initialize.test.ts +++ b/src/__tests__/CodexACPAgent/initialize.test.ts @@ -1,7 +1,3 @@ -import {fileRevertCapability} from "../../SessionFileRevert"; -import {sessionDiscoveryCapability} from "../../SessionDiscovery"; -import {archiveCapability} from "../../SessionArchive"; -import {runtimeCapability} from "../../SessionRuntime"; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { CodexAcpServer } from '../../CodexAcpServer'; import * as acp from '@agentclientprotocol/sdk'; @@ -71,13 +67,8 @@ describe('CodexACPAgent - initialize', () => { }, authMethods: getCodexAuthMethods(), _meta: { - runtime: runtimeCapability(), - archive: archiveCapability(), - fileRevert: fileRevertCapability(), - discovery: sessionDiscoveryCapability(), steering: { supported: true, - idleBehavior: ["promptRequired"], }, }, }); @@ -89,13 +80,8 @@ describe('CodexACPAgent - initialize', () => { clientCapabilities: {_meta: {jetbrains: {air: {version: 1, capabilities: []}}}}, }); expect(result._meta).toEqual({ - runtime: runtimeCapability(), - archive: archiveCapability(), - fileRevert: fileRevertCapability(), - discovery: sessionDiscoveryCapability(), steering: { supported: true, - idleBehavior: ["promptRequired"], }, jetbrains: { air: { diff --git a/src/__tests__/CodexACPAgent/session-rewind-lifecycle.test.ts b/src/__tests__/CodexACPAgent/session-rewind-lifecycle.test.ts index a707c6709..99adaf5de 100644 --- a/src/__tests__/CodexACPAgent/session-rewind-lifecycle.test.ts +++ b/src/__tests__/CodexACPAgent/session-rewind-lifecycle.test.ts @@ -8,7 +8,7 @@ import type {CodexAcpServer, SessionState} from "../../CodexAcpServer"; const sessionId = "session-id"; const request = {sessionId, beforeMessage:{messageId:"user",messageFingerprint:`sha256:${createHash("sha256").update("original").digest("hex")}`,messageOccurrence:1}}; -const steer = {sessionId,prompt:[{type:"text",text:"followup"}],_meta:{steering:{idleBehavior:"promptRequired"}}}; +const steer = {sessionId,prompt:[{type:"text",text:"followup"}]}; function deferred() {let resolve!: (value:T)=>void; const promise=new Promise(r=>{resolve=r;});return {promise,resolve};} function setup() { const fixture=createCodexMockTestFixture(); @@ -33,7 +33,6 @@ describe("rewind lifecycle",()=>{ ()=>agent.extMethod(SESSION_STEERING_METHOD,steer), ()=>agent.setSessionMode({sessionId,modeId:"read-only"}), ()=>agent.setSessionConfigOption({sessionId,configId:"model",value:"m"}), - ()=>agent.readSessionRuntime({sessionId,resource:"context"}), ]) await expect(attempt()).rejects.toThrow(); gate.resolve({rewound:true});await pending; call.mockRejectedValueOnce(new Error("invalid target")); @@ -93,20 +92,7 @@ describe("rewind lifecycle",()=>{ }); }); -describe("opt-in steering",()=>{ - it("returns promptRequired while idle without calling turn/start",async()=>{ - const {agent,native}=setup();const start=vi.spyOn(native,"turnStart"); - await expect(agent.extMethod(SESSION_STEERING_METHOD,steer)).resolves.toEqual({outcome:"promptRequired"}); - expect(start).not.toHaveBeenCalled(); - }); - it("returns promptRequired if the native turn ends before steer is accepted",async()=>{ - const {agent,native,state}=setup();state.currentTurnId="active"; - const gate=deferred();const call=vi.spyOn(native,"turnSteer").mockReturnValue(gate.promise); - const pending=agent.extMethod(SESSION_STEERING_METHOD,steer); - await vi.waitFor(()=>expect(call).toHaveBeenCalledOnce()); - state.currentTurnId=null;gate.resolve(Promise.reject(Error("no active turn to steer")) as never); - await expect(pending).resolves.toEqual({outcome:"promptRequired"}); - }); +describe("steering lifecycle",()=>{ it("blocks rewind while a steer is already in flight",async()=>{ const {agent,native,client,state}=setup();state.currentTurnId="active"; const gate=deferred();const call=vi.spyOn(native,"turnSteer").mockReturnValue(gate.promise); @@ -116,102 +102,3 @@ describe("opt-in steering",()=>{ expect(revert).not.toHaveBeenCalled();gate.resolve({} as never);await expect(pending).resolves.toEqual({outcome:"injected"}); }); }); - -describe("runtime lifecycle",()=>{ - it("requires provider idle for MCP reconnect, and fences all sessions while reconnecting",async()=>{ - const {agent,client}=setup();const gate=deferred();const reload=vi.spyOn(client,"reloadMcpServers").mockReturnValue(gate.promise); - const pending=agent.controlSessionRuntime({sessionId,action:"reconnectMcp"});await vi.waitFor(()=>expect(reload).toHaveBeenCalledOnce()); - await expect(agent.prompt({sessionId,prompt:[]})).rejects.toMatchObject({data:expect.stringContaining("Runtime control")}); - await expect(rewind(agent)).rejects.toMatchObject({data:expect.stringContaining("Runtime control")}); - await expect(agent.closeSession({sessionId})).rejects.toMatchObject({data:expect.stringContaining("Runtime control")}); - await expect(agent.setProvider({providerId:OPENAI_PROVIDER_ID,apiType:"openai",baseUrl:"https://example.test"})).rejects.toMatchObject({data:expect.stringContaining("Runtime control")}); - gate.resolve();await expect(pending).resolves.toMatchObject({status:"ok",data:{scope:"provider",connectionsReady:false}}); - }); - it("rejects reconnect while a prompt is active",async()=>{ - const {agent,fixture,client,native}=setup();mockPromptTurn(fixture,sessionId);const finish=deferred(); - vi.spyOn(native,"awaitTurnCompleted").mockReturnValue(finish.promise); - const pending=agent.prompt({sessionId,prompt:[{type:"text",text:"running"}]}); - await vi.waitFor(()=>expect(agent.getSessionState(sessionId).currentTurnId).toBe("turn-id")); - const reload=vi.spyOn(client,"reloadMcpServers");await expect(agent.controlSessionRuntime({sessionId,action:"reconnectMcp"})).rejects.toMatchObject({data:expect.stringContaining("Provider is busy")});expect(reload).not.toHaveBeenCalled(); - await agent.closeSession({sessionId});await pending; - }); - it("republishes available commands after reloadSkills and releases the control fence",async()=>{ - const {agent,client}=setup();const skills=vi.spyOn(client,"listSkills").mockResolvedValue({data:[]}); - const publish=vi.spyOn(agent as unknown as {publishAvailableCommands:()=>Promise},"publishAvailableCommands").mockResolvedValue(); - await agent.controlSessionRuntime({sessionId,action:"reloadSkills"});expect(skills).toHaveBeenCalledWith({cwds:["/test/cwd"],forceReload:true});expect(publish).toHaveBeenCalledOnce(); - await expect(agent.extMethod(SESSION_STEERING_METHOD,steer)).resolves.toEqual({outcome:"promptRequired"}); - }); -}); - - -describe("native pending queue admission", () => { - it.each(["load", "resume"] as const)("does not reopen after close during %s queue preflight", async method => { - const {agent,client,native} = setup(); - client.queueSupport = {nativeVersion:"0.160.1",actions:["list"]}; - const pending = deferred<{data:[],nextCursor:null}>(); - const list = vi.fn().mockReturnValue(pending.promise); - vi.spyOn(native,"queueNative").mockReturnValue({list} as never); - const resume = vi.spyOn(client,"resumeSession"); - const load = vi.spyOn(client,"loadSession"); - const opening = method === "load" ? agent.loadSession({sessionId,cwd:"/test/cwd",mcpServers:[]}) : agent.resumeSession({sessionId,cwd:"/test/cwd",mcpServers:[]}); - const rejection = expect(opening).rejects.toMatchObject({data:expect.stringContaining("changed while checking")}); - await vi.waitFor(()=>expect(list).toHaveBeenCalledOnce()); - await agent.closeSession({sessionId}); - pending.resolve({data:[],nextCursor:null}); - await rejection; - expect(resume).not.toHaveBeenCalled();expect(load).not.toHaveBeenCalled(); - }); - it("does not read or resume a session while close already holds its fence", async () => { - const {agent,client,native} = setup(); - client.queueSupport={nativeVersion:"0.160.1",actions:["list"]}; - const release=deferred();vi.spyOn(client,"closeSession").mockReturnValue(release.promise); - const closing=agent.closeSession({sessionId}); - const list=vi.fn();vi.spyOn(native,"queueNative").mockReturnValue({list} as never); - await expect(agent.resumeSession({sessionId,cwd:"/test/cwd",mcpServers:[]})).rejects.toMatchObject({data:expect.stringContaining("closing")}); - expect(list).not.toHaveBeenCalled();release.resolve();await closing; - }); - it("rejects persisted pending work before native resume can auto-dispatch", async () => { - const {agent,client,native}=setup();client.queueSupport={nativeVersion:"0.160.1",actions:["list"]}; - vi.spyOn(native,"queueNative").mockReturnValue({list:vi.fn().mockResolvedValue({data:[{id:"queued"}],nextCursor:null})} as never); - const resume=vi.spyOn(client,"resumeSession"); - await expect(agent.resumeSession({sessionId,cwd:"/test/cwd",mcpServers:[]})).rejects.toMatchObject({data:expect.stringContaining("pending queue")}); - expect(resume).not.toHaveBeenCalled(); - }); -}); - - -describe("provider control recovery", () => { - it("invalidates sessions after a hung control, keeps close reachable and rejects late installation", async () => { - vi.useFakeTimers(); - try { - const {agent,client}=setup(); - const native = deferred(); - vi.spyOn(client,"reloadMcpServers").mockReturnValue(native.promise); - const pending=agent.controlSessionRuntime({sessionId,action:"reconnectMcp"}); - const failure=expect(pending).rejects.toThrow("timed out"); - await vi.advanceTimersByTimeAsync(30_000); - await failure; - await expect(agent.closeSession({sessionId})).resolves.toEqual({}); - await expect(agent.newSession({cwd:"/test/cwd",mcpServers:[]})).rejects.toMatchObject({data:expect.stringContaining("invalidated")}); - await expect(agent.setProvider({providerId:OPENAI_PROVIDER_ID,apiType:"openai",baseUrl:"https://example.test"})).rejects.toMatchObject({data:expect.stringContaining("invalidated")}); - native.resolve();await Promise.resolve(); - expect(()=>agent.getSessionState(sessionId)).toThrow(); - } finally { vi.useRealTimers(); } - }); - it("reserves creation before an asynchronous new session can install", async () => { - const {agent,client,state}=setup(); - const created=deferred(); - const open=vi.spyOn(agent as unknown as {getOrCreateSession:()=>Promise},"getOrCreateSession").mockReturnValue(created.promise); - const pending=agent.newSession({cwd:"/test/cwd",mcpServers:[]}); - await vi.waitFor(()=>expect(open).toHaveBeenCalledOnce()); - const reload=vi.spyOn(client,"reloadMcpServers"); - await expect(agent.controlSessionRuntime({sessionId,action:"reconnectMcp"})).rejects.toMatchObject({data:expect.stringContaining("Provider is busy")}); - await expect(agent.revertFiles({sessionId,toolCallId:"file",dryRun:true})).rejects.toMatchObject({data:expect.stringContaining("Provider is busy")}); - await expect(agent.setProvider({providerId:OPENAI_PROVIDER_ID,apiType:"openai",baseUrl:"https://example.test"})).rejects.toThrow(); - expect(reload).not.toHaveBeenCalled(); - created.resolve([sessionId,{currentModelId:state.currentModelId,availableModels:[]},{currentModeId:"agent",availableModes:[]}] as never); - await pending; - reload.mockResolvedValue(); - await expect(agent.controlSessionRuntime({sessionId,action:"reconnectMcp"})).resolves.toMatchObject({status:"ok"}); - }); -}); diff --git a/src/__tests__/SessionDiscovery.test.ts b/src/__tests__/SessionDiscovery.test.ts deleted file mode 100644 index 1d94faae8..000000000 --- a/src/__tests__/SessionDiscovery.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import {describe, expect, it, vi} from "vitest"; -import {searchSessions, sessionAttachments, sessionAttachmentsParser, sessionSearchParser, type SessionDiscoveryClient} from "../SessionDiscovery"; -import type {Thread} from "../app-server/v2"; - -describe("native session discovery", () => { - it("rejects invalid search ranges and arbitrary native parameters", () => { - for (const p of [{searchTerm: " "}, {searchTerm: "x", limit: 0}, {searchTerm: "x", limit: 101}, {searchTerm: "x", method: "thread/delete"}]) { - expect(sessionSearchParser.safeParse(p).success).toBe(false); - } - }); - it("preserves the native cursor and snippet without leaking runtime config", async () => { - const native = {threadSearch: vi.fn(async () => ({data: [{thread: {id: "s", cwd: "/work", name: "name", updatedAt: 42, modelProvider: "private-config"} as Thread, snippet: "match"}], nextCursor: "opaque"}))}; - expect(await searchSessions(native, {searchTerm: "match", archived: true, limit: 20})).toEqual({version: 1, sessions: [{sessionId: "s", cwd: "/work", title: "name", updatedAt: 42, snippet: "match"}], nextCursor: "opaque"}); - expect(native.threadSearch).toHaveBeenCalledWith({searchTerm: "match", archived: true, limit: 20}); - }); - it("bounds attachment metadata and rejects extra destinations", () => { - const base = {sessionId: "s", action: "add", attachmentType: "pull_request", identityKey: "https://example.test/pr/1"}; - expect(sessionAttachmentsParser.safeParse({...base, payload: {title: "PR"}}).success).toBe(true); - expect(sessionAttachmentsParser.safeParse({...base, payload: "x".repeat(256 * 1024)}).success).toBe(false); - expect(sessionAttachmentsParser.safeParse({...base, payload: {}, threadId: "other"}).success).toBe(false); - }); - it("uses the checked session identity for every attachment operation", async () => { - const add = vi.fn(async () => ({outcome: "created", attachment: {id: "a"}})); - const native = {threadAttachmentAdd: add} as unknown as SessionDiscoveryClient; - const request = {sessionId: "s", action: "add" as const, attachmentType: "pull_request", identityKey: "url", payload: {url: "https://example.test/pr/1"}}; - await expect(sessionAttachments(native, "other", request)).rejects.toThrow("Session mismatch"); - expect(add).not.toHaveBeenCalled(); - await sessionAttachments(native, "s", request); - expect(add).toHaveBeenCalledWith({threadId: "s", attachmentType: "pull_request", identityKey: "url", payload: request.payload}); - }); -}); diff --git a/src/__tests__/SessionFileRevert.test.ts b/src/__tests__/SessionFileRevert.test.ts deleted file mode 100644 index 50ec84f86..000000000 --- a/src/__tests__/SessionFileRevert.test.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { execFileSync } from "node:child_process"; -import { mkdtemp, mkdir, writeFile, readFile, rm } from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; -import type { Thread, ThreadItem } from "../app-server/v2"; -import { revertSessionFiles } from "../SessionFileRevert"; - -const directories: string[] = []; -afterEach(async () => { - await Promise.all( - directories - .splice(0) - .map((dir) => rm(dir, { recursive: true, force: true })), - ); -}); -async function setup(changes?: unknown[]) { - const cwd = await mkdtemp(path.join(os.tmpdir(), "codex-file-revert-")); - directories.push(cwd); - execFileSync("git", ["init", "-q", cwd], { windowsHide: true }); - await writeFile(path.join(cwd, "changed.txt"), "old\n"); - execFileSync("git", ["add", "."], { cwd, windowsHide: true }); - const index = await readFile(path.join(cwd, ".git/index")); - await writeFile(path.join(cwd, "changed.txt"), "new\n"); - await writeFile(path.join(cwd, "user.txt"), "keep user's work\n"); - const item = { - type: "fileChange", - id: "tool", - status: "completed", - changes: changes ?? [ - { - path: path.join(cwd, "changed.txt"), - kind: { type: "update", move_path: null }, - diff: "@@ -1 +1 @@\n-old\n+new\n", - }, - ], - } as ThreadItem; - const readHistory = async () => ({ - thread: { id: "session", turns: [{ id: "turn", items: [item] }] } as Thread, - }); - return { - cwd, - index, - readHistory, - session: { sessionId: "session", cwd, additionalDirectories: [] }, - request: { sessionId: "session", toolCallId: "tool", dryRun: true }, - }; -} -describe("native file patch revert", () => { - it("previews then reverses the Git text patch while preserving the real index and unrelated edits", async () => { - const f = await setup(); - const preview = await revertSessionFiles( - f.request, - f.session, - f.readHistory, - ); - expect(preview.canRevert).toBe(true); - expect(await readFile(path.join(f.cwd, "changed.txt"), "utf8")).toBe( - "new\n", - ); - const applied = await revertSessionFiles( - { ...f.request, dryRun: false, previewToken: preview.previewToken }, - f.session, - f.readHistory, - ); - expect(applied.reverted).toBe(true); - expect( - (await readFile(path.join(f.cwd, "changed.txt"), "utf8")).replaceAll( - "\r\n", - "\n", - ), - ).toBe("old\n"); - expect(await readFile(path.join(f.cwd, "user.txt"), "utf8")).toBe("keep user's work\n"); - expect(await readFile(path.join(f.cwd, ".git/index"))).toEqual(f.index); - }); - it("refuses a changed file after preview and does not overwrite the user's new content", async () => { - const f = await setup(); - const preview = await revertSessionFiles( - f.request, - f.session, - f.readHistory, - ); - await writeFile(path.join(f.cwd, "changed.txt"), "new\nuser addition\n"); - expect( - await revertSessionFiles( - { ...f.request, dryRun: false, previewToken: preview.previewToken }, - f.session, - f.readHistory, - ), - ).toMatchObject({ reverted: false, reason: "stale_preview" }); - expect(await readFile(path.join(f.cwd, "changed.txt"), "utf8")).toBe( - "new\nuser addition\n", - ); - }); - it("rejects paths outside the session and Git metadata", async () => { - for (const file of ["../outside.txt", ".git/config"]) { - const f = await setup([ - { path: file, kind: { type: "add" }, diff: "x\n" }, - ]); - expect( - await revertSessionFiles(f.request, f.session, f.readHistory), - ).toMatchObject({ canRevert: false, reverted: false }); - } - }); - it("does not touch files without an explicit preview token", async () => { - const f = await setup(); - await expect( - revertSessionFiles( - { ...f.request, dryRun: false }, - f.session, - f.readHistory, - ), - ).rejects.toThrow(); - }); - it("undoes recorded create/delete patches in one batch", async () => { - const f = await setup([ - { path: "created.txt", kind: { type: "add" }, diff: "created\n" }, - { path: "deleted.txt", kind: { type: "delete" }, diff: "deleted\n" }, - ]); - await writeFile(path.join(f.cwd, "created.txt"), "created\n"); - const preview = await revertSessionFiles( - f.request, - f.session, - f.readHistory, - ); - expect(preview.canRevert).toBe(true); - expect( - await revertSessionFiles( - { ...f.request, dryRun: false, previewToken: preview.previewToken }, - f.session, - f.readHistory, - ), - ).toMatchObject({ reverted: true }); - await expect(readFile(path.join(f.cwd, "created.txt"))).rejects.toThrow(); - expect( - (await readFile(path.join(f.cwd, "deleted.txt"), "utf8")).replaceAll( - "\r\n", - "\n", - ), - ).toBe("deleted\n"); - }); - it("refuses a patch whose current content conflicts", async () => { - const f = await setup(); - await writeFile(path.join(f.cwd, "changed.txt"), "different\n"); - expect( - await revertSessionFiles(f.request, f.session, f.readHistory), - ).toMatchObject({ reason: "patch_conflict", canRevert: false }); - }); - it("refuses directory nodes as patch targets", async () => { - const f = await setup([ - { path: "folder", kind: { type: "add" }, diff: "x\n" }, - ]); - await mkdir(path.join(f.cwd, "folder")); - expect( - await revertSessionFiles(f.request, f.session, f.readHistory), - ).toMatchObject({ reason: "unsupported_file_type" }); - }); - it.each([{type:"futureKind"}, null, {}])("rejects an entire mixed batch with an unknown kind %j", async kind => { - const f = await setup([ - {path:"changed.txt",kind:{type:"update",move_path:null},diff:"@@ -1 +1 @@\n-old\n+new\n"}, - {path:"user.txt",kind,diff:"user content"}, - ]); - const result = await revertSessionFiles(f.request, f.session, f.readHistory); - expect(result).toMatchObject({canRevert:false,reverted:false,reason:"unsupported_patch"}); - expect(await readFile(path.join(f.cwd,"changed.txt"),"utf8")).toBe("new\n"); - expect(await readFile(path.join(f.cwd,"user.txt"),"utf8")).toBe("keep user's work\n"); - expect(await readFile(path.join(f.cwd,".git/index"))).toEqual(f.index); - }); - it("rejects a mixed batch containing a missing diff", async () => { - const f=await setup([{path:"changed.txt",kind:{type:"update",move_path:null},diff:"@@ -1 +1 @@\n-old\n+new\n"},{path:"user.txt",kind:{type:"add"}}]); - expect(await revertSessionFiles(f.request,f.session,f.readHistory)).toMatchObject({reverted:false,reason:"unsupported_patch"}); - expect(await readFile(path.join(f.cwd,"changed.txt"),"utf8")).toBe("new\n"); - }); - -}); diff --git a/src/__tests__/SessionQueue.test.ts b/src/__tests__/SessionQueue.test.ts deleted file mode 100644 index 6e5e5630a..000000000 --- a/src/__tests__/SessionQueue.test.ts +++ /dev/null @@ -1,196 +0,0 @@ -import {describe, expect, it, vi} from "vitest"; -import type {Turn} from "../app-server/v2/Turn"; -import { - SESSION_QUEUE_ACTIONS, SESSION_QUEUE_METHOD, parseSessionQueueRequest, - probeSessionQueueSupport, runSessionQueue, sessionQueueCapability, - type NativeQueueInput, type SessionQueueNative, type SessionQueueSupport, -} from "../SessionQueue"; - -const input: NativeQueueInput[] = [ - {type: "text", text: "正文", text_elements: [{byteRange: {start: 0, end: 6}, placeholder: null}]}, - {type: "text", text: " keep whitespace "}, - {type: "image", fileId: "file-id", detail: "original"}, - {type: "image", url: "data:image/png;base64,AA==", detail: null}, - {type: "localImage", path: "C:\\a.png"}, - {type: "audio", url: "data:audio/wav;base64,AA=="}, - {type: "localAudio", path: "C:\\a.wav"}, - {type: "skill", name: "skill", path: "C:\\SKILL.md"}, - {type: "mention", name: "file", path: "C:\\file.txt"}, -]; -const support: SessionQueueSupport = {nativeVersion: "0.160.1", actions: SESSION_QUEUE_ACTIONS}; -const queued = {id: "native-q", input, clientUserMessageId: "client-id", futureNativeField: {keep: true}}; -const turn: Turn = {id: "native-turn", items: [], itemsView: "notLoaded", status: "inProgress", error: null, startedAt: null, completedAt: null, durationMs: null}; - -function fixture() { - const native = { - list: vi.fn().mockResolvedValue({data: [queued], nextCursor: "opaque-next"}), - add: vi.fn().mockResolvedValue({queuedSubmission: queued}), - update: vi.fn().mockResolvedValue({queuedSubmission: queued}), - delete: vi.fn().mockResolvedValue({deleted: false}), - reorder: vi.fn().mockResolvedValue({}), - start: vi.fn().mockResolvedValue({turn}), - }; - const dependencies = {sessionId: "session", support, native}; - return {native, dependencies, run: (request: unknown) => runSessionQueue(request, dependencies)}; -} - -describe("session queue capability", () => { - it.each(["0.154.0", "0.159.3", "0.160.0", "0.160.1-preview.1", "codex 0.160.1", "2.1.1-preview", "", null, undefined])( - "does not probe or advertise unknown/old/unverified native %s", async version => { - const probe = vi.fn(async () => true); - const result = await probeSessionQueueSupport(version, probe); - expect(probe).not.toHaveBeenCalled(); - expect(result.actions).toEqual([]); - expect(sessionQueueCapability(result)).toBeUndefined(); - }, - ); - it("advertises only each action positively confirmed on the running native", async () => { - const result = await probeSessionQueueSupport("0.160.1", async action => { - if (action === "start") throw new Error("probe transport failed"); - return action === "list"; - }); - expect(sessionQueueCapability(result)).toEqual({version: 1, method: SESSION_QUEUE_METHOD, actions: ["list"]}); - expect(Object.isFrozen(result.actions)).toBe(true); - }); - it.each(["0.160.1", "0.161.0", "1.0.0"])("still requires positive evidence for %s", async version => { - const result = await probeSessionQueueSupport(version, async () => false); - expect(sessionQueueCapability(result)).toBeUndefined(); - }); - it("rejects an old native support record even if its actions were supplied", async () => { - const f = fixture(); - f.dependencies.support = {nativeVersion: "0.154.0", actions: SESSION_QUEUE_ACTIONS}; - await expect(f.run({sessionId: "session", action: "start"})).resolves.toEqual({status: "unsupported"}); - expect(f.native.start).not.toHaveBeenCalled(); - }); -}); - -describe("session queue native boundary", () => { - it("keeps native multimodal input, omission, IDs, and unknown RESULT fields", async () => { - const f = fixture(); - const result = {queuedSubmission: queued, nativeExtra: {revision: "native-owned"}}; - f.native.add.mockResolvedValue(result); - await expect(f.run({sessionId: "session", action: "add", input, clientUserMessageId: "client-id"})) - .resolves.toEqual({status: "ok", result}); - expect(f.native.add).toHaveBeenCalledExactlyOnceWith({threadId: "session", input, clientUserMessageId: "client-id"}); - expect(f.native.add.mock.calls[0]![0].input[1]).not.toHaveProperty("text_elements"); - expect(result.queuedSubmission.id).toBe("native-q"); - }); - it("preserves list pagination, nulls, and the native result object", async () => { - const f = fixture(); - const result = {data: [queued], nextCursor: "opaque-next", total: 200}; - f.native.list.mockResolvedValue(result); - const response = await f.run({sessionId: "session", action: "list", cursor: "opaque-in", limit: 0}); - expect(response.status === "ok" && response.result).toBe(result); - expect(f.native.list).toHaveBeenLastCalledWith({threadId: "session", cursor: "opaque-in", limit: 0}); - await f.run({sessionId: "session", action: "list", cursor: null, limit: null}); - expect(f.native.list).toHaveBeenLastCalledWith({threadId: "session", cursor: null, limit: null}); - await f.run({sessionId: "session", action: "list"}); - expect(f.native.list).toHaveBeenLastCalledWith({threadId: "session"}); - }); - it("updates only the named native submission without replacing its client ID", async () => { - const f = fixture(); - await expect(f.run({sessionId: "session", action: "update", queuedSubmissionId: "q", input})) - .resolves.toEqual({status: "ok", result: {queuedSubmission: queued}}); - expect(f.native.update).toHaveBeenCalledExactlyOnceWith({threadId: "session", queuedSubmissionId: "q", input}); - }); - it("keeps delete=false and does not invent successful removal", async () => { - const f = fixture(); - await expect(f.run({sessionId: "session", action: "delete", queuedSubmissionId: "gone"})) - .resolves.toEqual({status: "ok", result: {deleted: false}}); - expect(f.native.delete).toHaveBeenCalledExactlyOnceWith({threadId: "session", queuedSubmissionId: "gone"}); - }); - it("delegates native ordering and membership validation without local reordering", async () => { - const f = fixture(); - const ids = ["third", "first"]; - await expect(f.run({sessionId: "session", action: "reorder", queuedSubmissionIds: ids})) - .resolves.toEqual({status: "ok", result: {}}); - expect(f.native.reorder).toHaveBeenCalledExactlyOnceWith({threadId: "session", queuedSubmissionIds: ids}); - expect(f.native.list).not.toHaveBeenCalled(); - }); - it.each([{}, {queuedSubmissionId: null}, {queuedSubmissionId: "q"}])("only fake-starts once with native selector %j", async selector => { - const f = fixture(); - await expect(f.run({sessionId: "session", action: "start", ...selector})) - .resolves.toEqual({status: "ok", result: {turn}}); - expect(f.native.start).toHaveBeenCalledExactlyOnceWith({threadId: "session", ...selector}); - expect(f.native.delete).not.toHaveBeenCalled(); - }); - it.each(SESSION_QUEUE_ACTIONS)("never accesses another session for %s", async action => { - const f = fixture(); - const requests = { - list: {}, add: {input, clientUserMessageId: "id"}, update: {input, queuedSubmissionId: "id"}, - delete: {queuedSubmissionId: "id"}, reorder: {queuedSubmissionIds: []}, start: {}, - }; - await expect(f.run({sessionId: "other", action, ...requests[action]})).rejects.toThrow("bound session"); - for (const callback of Object.values(f.native)) expect(callback).not.toHaveBeenCalled(); - }); - it("rejects unproven start even when list was positively probed", async () => { - const f = fixture(); - f.dependencies.support = await probeSessionQueueSupport("0.160.1", async action => action === "list"); - await expect(f.run({sessionId: "session", action: "start"})).resolves.toEqual({status: "unsupported"}); - expect(f.native.start).not.toHaveBeenCalled(); - }); - it("reports a definitive method-not-found without retry or fallback", async () => { - const f = fixture(); - f.native.start.mockRejectedValue({code: -32601, message: "Method not found"}); - await expect(f.run({sessionId: "session", action: "start"})).resolves.toEqual({status: "unsupported"}); - expect(f.native.start).toHaveBeenCalledTimes(1); - }); - it.each([ - {code: -32602, message: "thread not found"}, - {code: -32603, message: "storage failed"}, - new Error("response lost after native commit"), - ])("propagates failures without replaying an uncertain mutation", async error => { - const f = fixture(); - f.native.add.mockRejectedValue(error); - await expect(f.run({sessionId: "session", action: "add", input, clientUserMessageId: "client-id"})).rejects.toBe(error); - expect(f.native.add).toHaveBeenCalledTimes(1); - expect(f.native.list).not.toHaveBeenCalled(); - }); -}); - -describe("session queue input validation", () => { - it.each([ - null, [], "list", {}, - {sessionId: "session", action: "thread/delete"}, - {sessionId: "session", action: "list", threadId: "other"}, - {sessionId: "session", action: "list", method: "thread/delete"}, - {sessionId: "session", action: "list", params: {threadId: "other"}}, - {sessionId: "session", action: "list", limit: -1}, - {sessionId: "session", action: "list", limit: 1.5}, - {sessionId: "session", action: "list", limit: 0x1_0000_0000}, - {sessionId: "session", action: "list", cursor: 1}, - {sessionId: " ", action: "list"}, - {sessionId: "session\n", action: "list"}, - {sessionId: "session", action: "add", input}, - {sessionId: "session", action: "add", input, clientUserMessageId: "id", revision: 1}, - {sessionId: "session", action: "start", input}, - {sessionId: "session", action: "start", queuedSubmissionId: ""}, - {sessionId: "session", action: "reorder", queuedSubmissionIds: [null]}, - {sessionId: "session", action: "update", input, queuedSubmissionId: "q", clientUserMessageId: "replacement"}, - ])("rejects malformed/extra fields before any native call: %j", async request => { - const f = fixture(); - await expect(f.run(request)).rejects.toThrow("Invalid session queue request"); - for (const callback of Object.values(f.native)) expect(callback).not.toHaveBeenCalled(); - }); - it.each([ - {type: "text", text: 42}, - {type: "text", text: "x", text_elements: [{byteRange: {start: -1, end: 1}}]}, - {type: "text", text: "x", text_elements: [{byteRange: {start: 0, end: 1}, placeholder: 7}]}, - {type: "image", url: "data:x", fileId: "ambiguous"}, - {type: "image", url: "data:x", detail: "unlimited"}, - {type: "image", fileId: "x", threadId: "other"}, - {type: "toolOutput", text: "inject"}, - {type: "localImage", path: 42}, - ])("rejects unknown/malformed native input: %j", block => { - expect(() => parseSessionQueueRequest({sessionId: "session", action: "add", input: [block], clientUserMessageId: "id"})) - .toThrow("Invalid session queue request"); - }); - it("does not echo rejected prompt/URL contents in errors", () => { - try { - parseSessionQueueRequest({sessionId: "session", action: "add", input: [{type: "unknown", secret: "sensitive"}], clientUserMessageId: "id"}); - expect.fail("should reject"); - } catch (error) { - expect(String(error)).not.toContain("sensitive"); - } - }); -}); diff --git a/src/__tests__/SessionRuntime.test.ts b/src/__tests__/SessionRuntime.test.ts deleted file mode 100644 index ddb78c88e..000000000 --- a/src/__tests__/SessionRuntime.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import {describe, expect, it, vi} from "vitest"; -import {controlRuntime, readRuntime, runtimeControlParser, runtimeReadParser, type RuntimeSession} from "../SessionRuntime"; - -const session: RuntimeSession = { - sessionId: "s", cwd: "/workspace", additionalDirectories: ["/extra"], currentModelId: "m", - lastTokenUsage: null, totalTokenUsage: null, modelContextWindow: null, rateLimits: null, -}; -const client = () => ({ - listSkills: vi.fn(async () => ({data: []})), - listMcpServers: vi.fn(async () => ({data: [], nextCursor: null})), - installedPlugins: vi.fn(async () => ({marketplaces: [], marketplaceLoadErrors: []})), - reconcilePlugins: vi.fn(async () => ({changedPlugins: [], failedRemotePluginIds: [], failedMaterializationRemotePluginIds: []})), - reloadMcpServers: vi.fn(async () => {}), -}); -describe("runtime extension", () => { - it("rejects arbitrary methods, extra native parameters and missing identity", () => { - for (const input of [{sessionId: "s", resource: "config"}, {resource: "mcp"}, {sessionId: "s", resource: "mcp", threadId: "other"}]) { - expect(runtimeReadParser.safeParse(input).success).toBe(false); - } - expect(runtimeControlParser.safeParse({sessionId: "s", action: "delete"}).success).toBe(false); - }); - it("preserves unavailable context measurements as null", async () => { - const result = await readRuntime(client(), session, {sessionId: "s", resource: "context"}, new AbortController().signal); - expect(result).toMatchObject({data: {totalTokens: null, maxTokens: null, categories: null}}); - }); - it("refreshes skills for the actual session roots", async () => { - const native = client(); - await controlRuntime(native, session, {sessionId: "s", action: "reloadSkills"}); - expect(native.listSkills).toHaveBeenCalledWith({cwds: ["/workspace", "/extra"], forceReload: true}); - }); - it("does not claim MCP readiness from a reload acknowledgement", async () => { - const native = client(); - expect(await controlRuntime(native, session, {sessionId: "s", action: "reconnectMcp"})).toMatchObject({data: {scope: "provider", connectionsReady: false}}); - expect(native.reloadMcpServers).toHaveBeenCalledOnce(); - }); - it("rejects mismatched sessions before the native call", async () => { - const native = client(); - await expect(controlRuntime(native, session, {sessionId: "other", action: "reloadSkills"})).rejects.toThrow("Session mismatch"); - expect(native.listSkills).not.toHaveBeenCalled(); - }); - it("does not start a cancelled status read", async () => { - const native = client(); const abort = new AbortController(); abort.abort(); - expect(await readRuntime(native, session, {sessionId: "s", resource: "commands"}, abort.signal)).toMatchObject({reason: "cancelled"}); - expect(native.listSkills).not.toHaveBeenCalled(); - }); - it("does not publish the old provider's answer after replacement", async () => { - const native = client(); let current = true; - native.listSkills.mockImplementation(async () => {current = false; return {data: []};}); - expect(await readRuntime(native, session, {sessionId: "s", resource: "commands"}, new AbortController().signal, () => current)).toMatchObject({reason: "stale"}); - }); -}); diff --git a/src/__tests__/scenarios/baseline.ts b/src/__tests__/scenarios/baseline.ts index 0a69b24db..a7fe89b96 100644 --- a/src/__tests__/scenarios/baseline.ts +++ b/src/__tests__/scenarios/baseline.ts @@ -1,7 +1,3 @@ -import {fileRevertCapability} from "../../SessionFileRevert"; -import {runtimeCapability} from "../../SessionRuntime"; -import {archiveCapability} from "../../SessionArchive"; -import {sessionDiscoveryCapability} from "../../SessionDiscovery"; import {canonical, type RecordedMessage} from "./scenario-harness"; /** @@ -145,16 +141,6 @@ const MCP_COMMAND = { export function withFeatureChanges(messages: RecordedMessage[]): RecordedMessage[] { const baselineMcp = JSON.stringify(canonical(BASELINE_MCP_COMMAND)); return messages.map(message => { - if (message.direction === "response" && message.method === "initialize") { - const params = structuredClone(message.params) as Json; - const meta = params["_meta"] as Json; - meta["runtime"] = runtimeCapability(); - meta["archive"] = archiveCapability(); - meta["fileRevert"] = fileRevertCapability(); - meta["discovery"] = sessionDiscoveryCapability(); - (meta["steering"] as Json)["idleBehavior"] = ["promptRequired"]; - return {...message, params}; - } const update = sessionUpdate(message); if (update === undefined || update["sessionUpdate"] !== "available_commands_update") return message; const commands = (update["availableCommands"] as unknown[]).map(command => diff --git a/src/__tests__/scenarios/data/air/agent-message-and-reasoning.jsonl b/src/__tests__/scenarios/data/air/agent-message-and-reasoning.jsonl index 67092c0fe..111ed4b63 100644 --- a/src/__tests__/scenarios/data/air/agent-message-and-reasoning.jsonl +++ b/src/__tests__/scenarios/data/air/agent-message-and-reasoning.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/background-terminal.jsonl b/src/__tests__/scenarios/data/air/background-terminal.jsonl index 8986d133b..e5b28624c 100644 --- a/src/__tests__/scenarios/data/air/background-terminal.jsonl +++ b/src/__tests__/scenarios/data/air/background-terminal.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/collab-agent.jsonl b/src/__tests__/scenarios/data/air/collab-agent.jsonl index b506fea43..cb95c1215 100644 --- a/src/__tests__/scenarios/data/air/collab-agent.jsonl +++ b/src/__tests__/scenarios/data/air/collab-agent.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/collab-controls.jsonl b/src/__tests__/scenarios/data/air/collab-controls.jsonl index d7f789cbc..a970ca093 100644 --- a/src/__tests__/scenarios/data/air/collab-controls.jsonl +++ b/src/__tests__/scenarios/data/air/collab-controls.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/command-approval.jsonl b/src/__tests__/scenarios/data/air/command-approval.jsonl index 715411f9f..6fd1e68de 100644 --- a/src/__tests__/scenarios/data/air/command-approval.jsonl +++ b/src/__tests__/scenarios/data/air/command-approval.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/command-failed.jsonl b/src/__tests__/scenarios/data/air/command-failed.jsonl index 2b976ca80..ef99b4c65 100644 --- a/src/__tests__/scenarios/data/air/command-failed.jsonl +++ b/src/__tests__/scenarios/data/air/command-failed.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/command-output-stdin.jsonl b/src/__tests__/scenarios/data/air/command-output-stdin.jsonl index 44379acf5..8a8aa2fac 100644 --- a/src/__tests__/scenarios/data/air/command-output-stdin.jsonl +++ b/src/__tests__/scenarios/data/air/command-output-stdin.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/command-without-streamed-output.jsonl b/src/__tests__/scenarios/data/air/command-without-streamed-output.jsonl index 5942fc210..cdd05b3c5 100644 --- a/src/__tests__/scenarios/data/air/command-without-streamed-output.jsonl +++ b/src/__tests__/scenarios/data/air/command-without-streamed-output.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/context-compaction.jsonl b/src/__tests__/scenarios/data/air/context-compaction.jsonl index 9499ff51e..a87cf8139 100644 --- a/src/__tests__/scenarios/data/air/context-compaction.jsonl +++ b/src/__tests__/scenarios/data/air/context-compaction.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/dynamic-tool.jsonl b/src/__tests__/scenarios/data/air/dynamic-tool.jsonl index 14167c453..e8c2f584f 100644 --- a/src/__tests__/scenarios/data/air/dynamic-tool.jsonl +++ b/src/__tests__/scenarios/data/air/dynamic-tool.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/file-change-approval.jsonl b/src/__tests__/scenarios/data/air/file-change-approval.jsonl index fca2fdb21..54c749c39 100644 --- a/src/__tests__/scenarios/data/air/file-change-approval.jsonl +++ b/src/__tests__/scenarios/data/air/file-change-approval.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/file-changes.jsonl b/src/__tests__/scenarios/data/air/file-changes.jsonl index f5e7b4f83..c563ef108 100644 --- a/src/__tests__/scenarios/data/air/file-changes.jsonl +++ b/src/__tests__/scenarios/data/air/file-changes.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/fuzzy-file-search.jsonl b/src/__tests__/scenarios/data/air/fuzzy-file-search.jsonl index eef4ce80b..f2cc4dfcd 100644 --- a/src/__tests__/scenarios/data/air/fuzzy-file-search.jsonl +++ b/src/__tests__/scenarios/data/air/fuzzy-file-search.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/goal-update.jsonl b/src/__tests__/scenarios/data/air/goal-update.jsonl index 8e02886a4..1b809b8ad 100644 --- a/src/__tests__/scenarios/data/air/goal-update.jsonl +++ b/src/__tests__/scenarios/data/air/goal-update.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/guardian-review.jsonl b/src/__tests__/scenarios/data/air/guardian-review.jsonl index 37ecf7e92..5e346387e 100644 --- a/src/__tests__/scenarios/data/air/guardian-review.jsonl +++ b/src/__tests__/scenarios/data/air/guardian-review.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/history-replay.jsonl b/src/__tests__/scenarios/data/air/history-replay.jsonl index a75be75e3..d1e6f13a3 100644 --- a/src/__tests__/scenarios/data/air/history-replay.jsonl +++ b/src/__tests__/scenarios/data/air/history-replay.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/load","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/image-generation.jsonl b/src/__tests__/scenarios/data/air/image-generation.jsonl index b2e206d8a..96afe2d86 100644 --- a/src/__tests__/scenarios/data/air/image-generation.jsonl +++ b/src/__tests__/scenarios/data/air/image-generation.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/image-view.jsonl b/src/__tests__/scenarios/data/air/image-view.jsonl index 9a644c604..69465e3ca 100644 --- a/src/__tests__/scenarios/data/air/image-view.jsonl +++ b/src/__tests__/scenarios/data/air/image-view.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/late-subagent-update.jsonl b/src/__tests__/scenarios/data/air/late-subagent-update.jsonl index 6a370e78a..263648cc1 100644 --- a/src/__tests__/scenarios/data/air/late-subagent-update.jsonl +++ b/src/__tests__/scenarios/data/air/late-subagent-update.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/mcp-elicitation.jsonl b/src/__tests__/scenarios/data/air/mcp-elicitation.jsonl index f3bd64604..41d5b7fe4 100644 --- a/src/__tests__/scenarios/data/air/mcp-elicitation.jsonl +++ b/src/__tests__/scenarios/data/air/mcp-elicitation.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/mcp-startup-failure.jsonl b/src/__tests__/scenarios/data/air/mcp-startup-failure.jsonl index b9cc913bb..9560eb4d6 100644 --- a/src/__tests__/scenarios/data/air/mcp-startup-failure.jsonl +++ b/src/__tests__/scenarios/data/air/mcp-startup-failure.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"content":[{"content":{"text":"[codex-acp forwarded startup error] MCP server `db` failed to start: boom","type":"text"},"type":"content"}],"kind":"other","sessionUpdate":"tool_call","status":"failed","title":"mcp__db__startup","toolCallId":"mcp_startup.db."}}} diff --git a/src/__tests__/scenarios/data/air/mcp-tool-approval.jsonl b/src/__tests__/scenarios/data/air/mcp-tool-approval.jsonl index 80dce2729..fb979992d 100644 --- a/src/__tests__/scenarios/data/air/mcp-tool-approval.jsonl +++ b/src/__tests__/scenarios/data/air/mcp-tool-approval.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/mcp-tool.jsonl b/src/__tests__/scenarios/data/air/mcp-tool.jsonl index 4a133826f..0690b2180 100644 --- a/src/__tests__/scenarios/data/air/mcp-tool.jsonl +++ b/src/__tests__/scenarios/data/air/mcp-tool.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/mcp-url-elicitation.jsonl b/src/__tests__/scenarios/data/air/mcp-url-elicitation.jsonl index c0e30ed53..a2cff77c8 100644 --- a/src/__tests__/scenarios/data/air/mcp-url-elicitation.jsonl +++ b/src/__tests__/scenarios/data/air/mcp-url-elicitation.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/native-subagent-session.jsonl b/src/__tests__/scenarios/data/air/native-subagent-session.jsonl index d6360e99a..96a3405e0 100644 --- a/src/__tests__/scenarios/data/air/native-subagent-session.jsonl +++ b/src/__tests__/scenarios/data/air/native-subagent-session.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/nested-subagent-session.jsonl b/src/__tests__/scenarios/data/air/nested-subagent-session.jsonl index 0f9860399..3df8ccddc 100644 --- a/src/__tests__/scenarios/data/air/nested-subagent-session.jsonl +++ b/src/__tests__/scenarios/data/air/nested-subagent-session.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/network-and-sandbox-approval.jsonl b/src/__tests__/scenarios/data/air/network-and-sandbox-approval.jsonl index 379fd626d..9b85476ec 100644 --- a/src/__tests__/scenarios/data/air/network-and-sandbox-approval.jsonl +++ b/src/__tests__/scenarios/data/air/network-and-sandbox-approval.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/plan-deltas-and-turn-plan.jsonl b/src/__tests__/scenarios/data/air/plan-deltas-and-turn-plan.jsonl index 7a1acd0b2..7a2a314f3 100644 --- a/src/__tests__/scenarios/data/air/plan-deltas-and-turn-plan.jsonl +++ b/src/__tests__/scenarios/data/air/plan-deltas-and-turn-plan.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/plan-review-permission.jsonl b/src/__tests__/scenarios/data/air/plan-review-permission.jsonl index 2c2b2812e..e0abf6e00 100644 --- a/src/__tests__/scenarios/data/air/plan-review-permission.jsonl +++ b/src/__tests__/scenarios/data/air/plan-review-permission.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"plan","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/plan-stream.jsonl b/src/__tests__/scenarios/data/air/plan-stream.jsonl index bdd6b2798..12e98fbfc 100644 --- a/src/__tests__/scenarios/data/air/plan-stream.jsonl +++ b/src/__tests__/scenarios/data/air/plan-stream.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/read-search-list.jsonl b/src/__tests__/scenarios/data/air/read-search-list.jsonl index 81d5d8e1e..9785dd9bd 100644 --- a/src/__tests__/scenarios/data/air/read-search-list.jsonl +++ b/src/__tests__/scenarios/data/air/read-search-list.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/subagent-activity.jsonl b/src/__tests__/scenarios/data/air/subagent-activity.jsonl index 3280160b7..f48d175f8 100644 --- a/src/__tests__/scenarios/data/air/subagent-activity.jsonl +++ b/src/__tests__/scenarios/data/air/subagent-activity.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/web-search.jsonl b/src/__tests__/scenarios/data/air/web-search.jsonl index f30c28549..e7154a72f 100644 --- a/src/__tests__/scenarios/data/air/web-search.jsonl +++ b/src/__tests__/scenarios/data/air/web-search.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/scenario-harness.ts b/src/__tests__/scenarios/scenario-harness.ts index fee262ed9..498ef1fc2 100644 --- a/src/__tests__/scenarios/scenario-harness.ts +++ b/src/__tests__/scenarios/scenario-harness.ts @@ -97,10 +97,7 @@ export async function runScenario( } const messages = await runInWorkspace(replaceText(scenario, WS, workspace), profile, workspace); // A Git patch names the paths without the leading slash. - // Native locations use platform separators; Git patch headers always use '/'. - const local = replaceText(replaceText(messages, workspace, WS), workspace.replaceAll("\\", "/"), WS); - return JSON.parse(JSON.stringify(local, (_key, value) => typeof value === "string" - ? value.replaceAll("/workspace\\", "/workspace/").replaceAll("/workspace/src\\", "/workspace/src/").replaceAll("a//workspace/", "a/workspace/").replaceAll("b//workspace/", "b/workspace/").replaceAll("from /workspace/", "from workspace/").replaceAll("to /workspace/", "to workspace/") : value)); + return replaceText(replaceText(messages, workspace, WS), workspace.slice(1), WS.slice(1)); } finally { fs.rmSync(workspace, {recursive: true, force: true}); } diff --git a/src/index.ts b/src/index.ts index cfdb567fb..ff5179d9f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,4 @@ #!/usr/bin/env node -import {SESSION_FILE_REVERT_METHOD, sessionFileRevertParser} from "./SessionFileRevert"; -import {SESSION_QUEUE_METHOD, parseSessionQueueRequest} from "./SessionQueue"; -import {SESSION_SEARCH_METHOD, SESSION_ATTACHMENTS_METHOD, sessionSearchParser, sessionAttachmentsParser} from "./SessionDiscovery"; -import {SESSION_ARCHIVE_METHOD, SESSION_UNARCHIVE_METHOD, sessionArchiveParser} from "./SessionArchive"; import * as acp from "@agentclientprotocol/sdk"; import {z} from "zod"; @@ -24,7 +20,6 @@ import { } from "./AcpExtensions"; import {ASYNC_TASK_STOP_METHOD} from "./async-tasks/AsyncTaskExtension"; -import {RUNTIME_READ_METHOD, RUNTIME_CONTROL_METHOD, runtimeReadParser, runtimeControlParser} from "./SessionRuntime"; import {SESSION_REWIND_METHOD} from "./SessionRewind"; const emptyExtensionParamsParser = z.preprocess( @@ -196,14 +191,6 @@ function startAcpServer() { .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(ASYNC_TASK_STOP_METHOD, asyncTaskStopParamsParser, (ctx) => getAgent().extMethod(ASYNC_TASK_STOP_METHOD, ctx.params)) - .onRequest(SESSION_FILE_REVERT_METHOD, sessionFileRevertParser, (ctx) => getAgent().revertFiles(ctx.params)) - .onRequest(SESSION_QUEUE_METHOD, z.unknown().transform(value => parseSessionQueueRequest(value)), (ctx) => getAgent().manageSessionQueue(ctx.params, ctx.signal)) - .onRequest(SESSION_SEARCH_METHOD, sessionSearchParser, (ctx) => getAgent().searchSessionHistory(ctx.params)) - .onRequest(SESSION_ATTACHMENTS_METHOD, sessionAttachmentsParser, (ctx) => getAgent().manageSessionAttachments(ctx.params)) - .onRequest(SESSION_ARCHIVE_METHOD, sessionArchiveParser, (ctx) => getAgent().archiveSession(ctx.params.sessionId, true)) - .onRequest(SESSION_UNARCHIVE_METHOD, sessionArchiveParser, (ctx) => getAgent().archiveSession(ctx.params.sessionId, false)) - .onRequest(RUNTIME_READ_METHOD, runtimeReadParser, (ctx) => getAgent().readSessionRuntime(ctx.params, ctx.signal)) - .onRequest(RUNTIME_CONTROL_METHOD, runtimeControlParser, (ctx) => getAgent().controlSessionRuntime(ctx.params)) .onRequest(SESSION_REWIND_METHOD, sessionRewindParamsParser, (ctx) => getAgent().extMethod(SESSION_REWIND_METHOD, ctx.params)) .onRequest(CODEX_HOOKS_LIST_METHOD, hooksListParamsParser, (ctx) => getAgent().listHooks(ctx.params.cwd)) .onRequest(CODEX_HOOKS_TRUST_METHOD, hooksTrustParamsParser, (ctx) => getAgent().trustHooks(ctx.params.cwd, ctx.params.hooks)) diff --git a/src/tool-calls/reporters/FuzzySearchReporter.ts b/src/tool-calls/reporters/FuzzySearchReporter.ts index 19f4d34aa..ec03b2114 100644 --- a/src/tool-calls/reporters/FuzzySearchReporter.ts +++ b/src/tool-calls/reporters/FuzzySearchReporter.ts @@ -19,7 +19,7 @@ export class FuzzySearchReporter { report: started ? "start" : "update", title: searchTitle(event.query, null), status: "in_progress", - locations: event.files.map(file => path.isAbsolute(file.path) ? file.path : (file.root.startsWith("/") ? path.posix : path).join(file.root, file.path)), + locations: event.files.map(file => path.isAbsolute(file.path) ? file.path : path.join(file.root, file.path)), }; return started ? {...facts, kind: "search", input: {query: event.query}} : facts; } From e8f0de1ef562a7637d33dc88df688c3124f23ce1 Mon Sep 17 00:00:00 2001 From: Wolf-L <50284298+Wolf-L@users.noreply.github.com> Date: Fri, 9 Oct 2026 07:30:27 +0800 Subject: [PATCH 7/8] test: make native rewind regressions reproducible on Windows and Linux --- .github/workflows/ci.yml | 23 ++ docs/session-rewind-extension.md | 29 ++ package.json | 3 +- readme-dev.md | 9 + scripts/native-e2e.mjs | 102 ++++++ scripts/native-e2e/probe.py | 535 +++++++++++++++++++++++++++++ scripts/native-e2e/spawn-audit.cjs | 13 + src/CodexJsonRpcConnection.ts | 6 +- 8 files changed, 716 insertions(+), 4 deletions(-) create mode 100644 scripts/native-e2e.mjs create mode 100644 scripts/native-e2e/probe.py create mode 100644 scripts/native-e2e/spawn-audit.cjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fc655efd6..b89f0a1fb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,3 +29,26 @@ jobs: run: npm test - name: Bundle binaries run: npm run bundle:all + + native-rewind: + name: Native rewind (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + permissions: + contents: read + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: "24" + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + - name: Install dependencies including the native binary + run: npm ci --include=optional + - name: Run isolated native conversation rewind + run: npm run test:native:rewind diff --git a/docs/session-rewind-extension.md b/docs/session-rewind-extension.md index f32f4b4df..330a8f67e 100644 --- a/docs/session-rewind-extension.md +++ b/docs/session-rewind-extension.md @@ -65,3 +65,32 @@ An active prompt is cancelled only after the target validates; the adapter waits History rewind does not restore files. The interface and original implementation come from [Nikita Ashikhmin's PR #508](https://github.com/agentclientprotocol/codex-acp/pull/508), preserved as four commits in this branch. This local integration merges current main and adds validation/lifecycle safeguards; it is not a new incompatible rewind protocol. + +## Native regression tests + +With Node.js 24+, Python 3 and the platform optional Codex dependency installed, +run `npm run test:native:rewind`. The runner builds this checkout, hashes source, +bundle and the resolved native executable, then launches real ACP/native +processes against a scripted loopback Responses provider. It requires no account +or external model. Only rewind/history/lifecycle scenarios are present; file +restore and other extension scenarios are excluded. + +The suite checks historical, latest and first-message rewind, same-ID cold +restart before resend, invalid and stale targets, exact repeated-message +identity, active-turn interruption, explicit cancellation, retained provider +context, native process identity and clean shutdown. No native transcript is +seeded or rewritten by the test client. The Windows/Linux native CI job runs +this independently of the upstream unit/build job. + +Each run creates an isolated home, workspace, config and temporary directory +under `tmp/native-e2e/run-*`. The child environment excludes inherited +credentials, provider overrides and user profiles. Model traffic is loopback; +an HTTP(S) deny proxy blocks incidental external requests, but is not an OS +network firewall. Artifact directories are retained for diagnosis, including +`build.json`, `report.json`, RPC/model traces and process-exit evidence. +Nonzero exit, missing acknowledgement, changed source/build, failed assertion +or unclean shutdown fails the run. + +Run `node scripts/native-e2e.mjs --rewind-only --wrong-reply` to verify the +failure path: it must exit 1 and record `success:false`. The optional +`--output ` selects an artifact parent inside this checkout only. diff --git a/package.json b/package.json index aa7c288f5..8908dfbe3 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,8 @@ "test:e2e": "npm run build && RUN_E2E_TESTS=true vitest run --no-file-parallelism --retry=2 src/__tests__/CodexACPAgent/e2e", "test:watch": "vitest", "typecheck": "tsc --noEmit && tsc --noEmit -p examples/tsconfig.json", - "codex-test": "tsx .claude/skills/run-codex/scripts/run-codex-test.ts" + "codex-test": "tsx .claude/skills/run-codex/scripts/run-codex-test.ts", + "test:native:rewind": "node scripts/native-e2e.mjs --rewind-only" }, "homepage": "https://github.com/agentclientprotocol/codex-acp#readme", "bugs": { diff --git a/readme-dev.md b/readme-dev.md index 4d3c617a6..f11b015af 100644 --- a/readme-dev.md +++ b/readme-dev.md @@ -102,3 +102,12 @@ the adapter does not rely on notices being displayed. Command replies, review results, and terminal/retrying errors retain their existing response or failure channels. Clients advertising session compaction support continue to receive the dedicated compaction lifecycle instead of the legacy completion advisory. + +### Isolated native rewind tests + +Run `npm run test:native:rewind` with Node 24+, Python 3, and the platform +optional Codex package installed. `NATIVE_E2E_PYTHON` overrides the Python +executable (default: `python` on Windows, `python3` elsewhere). The harness +uses an isolated child environment and local provider; it does not reuse your +Codex home or credentials. See [rewind regression tests](docs/session-rewind-extension.md#native-regression-tests) +for assertions, retained evidence and the intentional failure-path command. diff --git a/scripts/native-e2e.mjs b/scripts/native-e2e.mjs new file mode 100644 index 000000000..70b9399ec --- /dev/null +++ b/scripts/native-e2e.mjs @@ -0,0 +1,102 @@ +// No account, external provider, globally installed Codex, or saved fixture is used. +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repo = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const args = process.argv.slice(2); +let output, rewindOnly = false, wrongReply = false; +for (let i = 0; i < args.length; i++) { + if (args[i] === '--rewind-only' && !rewindOnly) rewindOnly = true; + else if (args[i] === '--wrong-reply' && !wrongReply) wrongReply = true; + else if (args[i] === '--output' && !output && args[i + 1] && !args[i + 1].startsWith('--')) output = args[++i]; + else { + console.error('Usage: npm run test:native:rewind -- [--rewind-only] [--wrong-reply] [--output ]'); + process.exit(2); + } +} +if (Number(process.versions.node.split('.')[0]) < 24) throw new Error('Node 24+ is required'); +const parent = path.resolve(output ?? path.join(repo, 'tmp', 'native-e2e')); +const relativeOutput = path.relative(repo, parent); +if (relativeOutput.startsWith('..') || path.isAbsolute(relativeOutput)) throw new Error('Artifact output must stay inside this checkout'); +mkdirSync(parent, { recursive: true }); +const run = mkdtempSync(path.join(parent, 'run-')); +for (const name of ['home', 'workspace', 'tmp', 'home/appdata', 'home/localappdata', 'home/.config', 'home/.cache']) { + mkdirSync(path.join(run, name), { recursive: true }); +} +// A whitelist deliberately excludes credentials, proxies, NODE_OPTIONS, Git +// overrides, Codex configuration, and the invoking user's profile directories. +const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => + ['PATH', 'SYSTEMROOT', 'WINDIR', 'COMSPEC', 'PATHEXT'].includes(key.toUpperCase()))); +Object.assign(env, { + HOME: path.join(run, 'home'), USERPROFILE: path.join(run, 'home'), + CODEX_HOME: path.join(run, 'home'), APPDATA: path.join(run, 'home/appdata'), + LOCALAPPDATA: path.join(run, 'home/localappdata'), + XDG_CONFIG_HOME: path.join(run, 'home/.config'), XDG_CACHE_HOME: path.join(run, 'home/.cache'), + TMP: path.join(run, 'tmp'), TEMP: path.join(run, 'tmp'), TMPDIR: path.join(run, 'tmp'), + GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: path.join(run, 'home/.gitconfig'), + GIT_OPTIONAL_LOCKS: '0', +}); +writeFileSync(env.GIT_CONFIG_GLOBAL, ''); +const hash = (file) => createHash('sha256').update(readFileSync(file)).digest('hex'); +function walk(dir) { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const file = path.join(dir, entry.name); + return entry.isDirectory() ? walk(file) : [file]; + }).sort(); +} +function sources() { + return Object.fromEntries([...walk(path.join(repo, 'src')), + ...['package.json', 'package-lock.json', 'build.mjs'].map((name) => path.join(repo, name))] + .map((file) => [path.relative(repo, file), hash(file)])); +} +function command(binary, argv) { + const result = spawnSync(binary, argv, { + cwd: repo, env, windowsHide: true, encoding: 'utf8', timeout: 120_000, + }); + if (result.error || result.status !== 0) { + throw new Error(`${binary} failed: ${result.error ?? result.stderr ?? result.status}`); + } + return result.stdout.trim(); +} +try { + const require = createRequire(path.join(repo, 'package.json')); + const codexManifest = require.resolve('@openai/codex/package.json'); + const installed = JSON.parse(readFileSync(codexManifest, 'utf8')); + const packageName = `@openai/codex-${process.platform}-${process.arch}`; + if (!installed.optionalDependencies?.[packageName]) throw new Error(`Unsupported native platform: ${packageName}`); + const nativeManifest = createRequire(codexManifest).resolve(`${packageName}/package.json`); + // Discover the actual installed layout; do not assume a vendor triple or bin/codex subdirectory. + const candidates = walk(path.join(path.dirname(nativeManifest), 'vendor')) + .filter((file) => path.basename(file) === (process.platform === 'win32' ? 'codex.exe' : 'codex')); + if (candidates.length !== 1) throw new Error(`Expected one installed native executable, found ${candidates.length}`); + const native = realpathSync(candidates[0]); + const before = sources(); + writeFileSync(path.join(run, 'build.log'), command(process.execPath, [path.join(repo, 'build.mjs')])); + if (JSON.stringify(before) !== JSON.stringify(sources())) throw new Error('Sources changed during build; rerun after concurrent edits finish'); + const metadata = { + repo, run, mode: 'rewind-only', wrongReply, node: process.execPath, nodeVersion: process.version, native, + nativePackage: packageName, codexVersion: installed.version, + nativeVersion: command(native, ['--version']), nativeSha256: hash(native), + distSha256: hash(path.join(repo, 'dist/index.js')), sources: before, + }; + const manifest = path.join(run, 'build.json'); + writeFileSync(manifest, JSON.stringify(metadata, null, 2) + '\n'); + const python = process.env.NATIVE_E2E_PYTHON || (process.platform === 'win32' ? 'python' : 'python3'); + console.log(`Native E2E artifacts: ${run}`); + const result = spawnSync(python, [path.join(repo, 'scripts/native-e2e/probe.py'), manifest], { + // Python bounds each RPC/child shutdown and cleans up in finally. Killing + // just Python on a launcher timeout would orphan its owned ACP/native tree. + cwd: repo, env, windowsHide: true, stdio: 'inherit', + }); + if (result.error) throw result.error; + process.exitCode = result.status ?? 1; +} catch (error) { + writeFileSync(path.join(run, 'launcher-failure.txt'), String(error.stack ?? error)); + console.error(error); + console.error(`Native E2E artifacts: ${run}`); + process.exitCode = 1; +} diff --git a/scripts/native-e2e/probe.py b/scripts/native-e2e/probe.py new file mode 100644 index 000000000..89987de96 --- /dev/null +++ b/scripts/native-e2e/probe.py @@ -0,0 +1,535 @@ +"""Real ACP/native processes over stdio, with a scripted loopback Responses server. + +Only the model is fake. Never read native rollout files, write ACP history, or +execute file tools in the test client. Invoke through ../native-e2e.mjs so every +run is fresh and bound to a just-built artifact. Python 3 standard library only. +""" +import gzip +import hashlib +import http.server +import json +import os +from pathlib import Path +import queue +import shutil +import signal +import subprocess +import sys +import threading +import time +import traceback + + +TIMEOUT = 35 +FLAGS = getattr(subprocess, "CREATE_NO_WINDOW", 0) + + +def require(condition, message): + # Do not use assert: python -O must still fail a broken native test. + if not condition: + raise AssertionError(message) + + +def sha(file): + return hashlib.sha256(Path(file).read_bytes()).hexdigest() + + +def save(file, value): + Path(file).write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf8") + + +def points(updates, kind="user_message_chunk"): + grouped = {} + for update in updates: + if update.get("sessionUpdate") != kind: + continue + mid = update.get("messageId") + content = update.get("content", {}) + require(mid and content.get("type") == "text", f"Missing native text identity: {update}") + grouped[mid] = grouped.get(mid, "") + content["text"] + seen, result = {}, [] + for mid, text in grouped.items(): + fingerprint = "sha256:" + hashlib.sha256(text.encode()).hexdigest() + seen[fingerprint] = seen.get(fingerprint, 0) + 1 + result.append({"messageId": mid, "messageFingerprint": fingerprint, + "messageOccurrence": seen[fingerprint], "text": text}) + return result + + +def point(value): + return {key: val for key, val in value.items() if key != "text"} + + +class Model: + def __init__(self, wrong_reply=False): + self.requests, self.title_requests, self.errors, self.blocked = [], [], [], [] + self.wrong_reply, self.injected, self.expected_answers = wrong_reply, False, [] + self.pending = queue.Queue() + self.seen, self.release = threading.Event(), threading.Event() + self.lock = threading.Lock() + owner = self + + class Deny(http.server.BaseHTTPRequestHandler): + def log_message(self, *_): + pass + + def reject(self): + owner.blocked.append({"method": self.command, "target": self.path}) + self.send_error(502, "Native E2E denies external HTTP egress") + + do_CONNECT = do_GET = do_POST = reject + + class Responses(http.server.BaseHTTPRequestHandler): + def log_message(self, *_): + pass + + def do_POST(self): + try: + require(self.path == "/v1/responses", f"Unexpected model path: {self.path}") + raw = self.rfile.read(int(self.headers.get("Content-Length", "0"))) + if self.headers.get("Content-Encoding") == "gzip": + raw = gzip.decompress(raw) + body = json.loads(raw) + require("Authorization" not in self.headers, "Unexpected model credentials") + # The adapter also makes ephemeral title turns. Recognize the + # actual schema AND title instruction, never just a model name + # or a sentinel that could appear in discarded user history. + schema = body.get("text", {}).get("format", {}).get("schema", {}) + title = (schema.get("required") == ["title"] + and schema.get("properties") == {"title": {"type": "string"}} + and "Your task is to generate a very short title for a conversation" in json.dumps(body.get("input", []))) + with owner.lock: + target = owner.title_requests if title else owner.requests + target.append({"path": self.path, "body": body}) + number = len(owner.requests) + len(owner.title_requests) + if title: + require(len(owner.title_requests) <= 8, "Unexpected title request loop") + action = "title" + else: + try: + action = owner.pending.get_nowait() + except queue.Empty: + raise AssertionError("Unsolicited model request (rewind/load must not resend)") + if action == "hold": + owner.seen.set() + require(owner.release.wait(TIMEOUT), "Held request was not released") + events = [{"type": "response.created", "response": {"id": f"resp-{number}"}}] + answer = '{"title":"Local native fixture"}' if title else f"LOCAL_ANSWER_{number}" + if not title: + owner.expected_answers.append(answer) + if owner.wrong_reply and not owner.injected: + answer = "INJECTED_WRONG_REPLY" + owner.injected = True + events.append({"type": "response.output_item.done", "item": { + "type": "message", "role": "assistant", "id": f"msg-{number}", + "content": [{"type": "output_text", "text": answer}]}}) + events.append({"type": "response.completed", "response": { + "id": f"resp-{number}", "usage": {"input_tokens": 10, "output_tokens": 2, "total_tokens": 12}}}) + data = "".join("data: " + json.dumps(event) + "\n\n" for event in events).encode() + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + try: + self.wfile.write(data) + except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError): + pass # Expected after cancellation of the held native turn. + except Exception as error: + owner.errors.append(str(error)) + self.send_error(500, "Local fixture failed; see report.json") + + self.proxy = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Deny) + self.server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Responses) + for server in (self.proxy, self.server): + threading.Thread(target=server.serve_forever, daemon=True).start() + + def arm(self, *actions): + require(self.pending.empty(), "Previous prompt did not consume its expected model calls") + for action in actions: + self.pending.put(action) + + def check(self): + require(not self.errors, f"Local model errors: {self.errors}") + require(self.pending.empty(), "Missing expected model call") + + def close(self): + self.release.set() + for server in (self.proxy, self.server): + server.shutdown() + server.server_close() + + +class Rpc: + def __init__(self, probe): + self.probe = probe + self.seq, self.stash, self.trace = 0, {}, [] + self.incoming = queue.Queue() + self.closed = False + self.number = len(probe.clients) + probe.clients.append(self) + self.stderr = (probe.run / f"stderr-{self.number}.log").open("w", encoding="utf8") + self.p = subprocess.Popen( + [probe.meta["node"], str(probe.repo / "dist/index.js")], cwd=probe.work, env=probe.env, + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=self.stderr, + text=True, encoding="utf8", creationflags=FLAGS, start_new_session=os.name != "nt") + self.reader_thread = threading.Thread(target=self.reader, daemon=True) + self.reader_thread.start() + self.init = self.call("initialize", { + "protocolVersion": 1, "clientInfo": {"name": "isolated-native-e2e", "version": "1"}, + "clientCapabilities": {"_meta": {"jetbrains": {"air": { + "version": 1, "capabilities": []}}}}}) + + def reader(self): + try: + for line in self.p.stdout: + try: + message = json.loads(line) + except json.JSONDecodeError: + self.incoming.put(RuntimeError(f"Non-JSON ACP stdout: {line[:200]}")) + continue + self.trace.append({"direction": "in", "message": message}) + self.incoming.put(message) + finally: + self.incoming.put(RuntimeError("ACP stdout closed before requested response")) + + def write(self, message): + self.trace.append({"direction": "out", "message": message}) + self.p.stdin.write(json.dumps(message) + "\n") + self.p.stdin.flush() + + def send(self, method, params): + self.seq += 1 + self.write({"jsonrpc": "2.0", "id": self.seq, "method": method, "params": params}) + return self.seq + + def notify(self, method, params): + self.write({"jsonrpc": "2.0", "method": method, "params": params}) + + def response(self, ident): + if ident in self.stash: + return self.stash.pop(ident) + deadline = time.monotonic() + TIMEOUT + while True: + try: + message = self.incoming.get(timeout=max(0.01, deadline - time.monotonic())) + except queue.Empty: + raise TimeoutError(f"ACP response {ident} timed out; inspect stderr-{self.number}.log") + if isinstance(message, Exception): + raise message + if "id" in message and "method" in message: + # Never emulate native apply_patch or other host tools. + self.write({"jsonrpc": "2.0", "id": message["id"], "error": { + "code": -32601, "message": "Native E2E client tools disabled"}}) + self.probe.client_requests.append(message) + elif "id" in message: + if message["id"] == ident: + return message + self.stash[message["id"]] = message + require(time.monotonic() < deadline, f"ACP response {ident} timed out") + + def raw(self, method, params): + return self.response(self.send(method, params)) + + def call(self, method, params): + message = self.raw(method, params) + require("error" not in message, f"{method}: {message}") + return message["result"] + + def new(self): + return self.call("session/new", {"cwd": str(self.probe.work), "mcpServers": []})["sessionId"] + + def prompt(self, sid, text): + self.probe.model.arm("answer") + result = self.call("session/prompt", {"sessionId": sid, "prompt": [{"type": "text", "text": text}]}) + require(result.get("stopReason") == "end_turn", result) + self.probe.model.check() + # The scripted SSE emits a completed item rather than text deltas. + # Verify the persisted native answer through the real ACP replay route. + answers = points(self.load(sid), "agent_message_chunk") + actual = answers[-1]["text"] if answers else "" + expected = self.probe.model.expected_answers[-1] + require(actual == expected, f"Assistant reply mismatch: expected {expected!r}, received {actual!r}") + return result + + def load(self, sid): + start = len(self.trace) + count = len(self.probe.model.requests) + result = self.call("session/load", {"sessionId": sid, "cwd": str(self.probe.work), "mcpServers": []}) + updates = [entry["message"]["params"] for entry in self.trace[start:] + if entry["direction"] == "in" and entry["message"].get("method") == "session/update"] + require(all(value["sessionId"] == sid for value in updates), "Load changed session ID") + require(result.get("sessionId", sid) == sid, "Load returned another session ID") + require(len(self.probe.model.requests) == count, "Load resent a prompt") + self.probe.model.check() + return [value["update"] for value in updates] + + def rewind(self, sid, before, resume=None): + request = {"sessionId": sid, "beforeMessage": point(before)} + if resume: + request["resumeAtMessage"] = point(resume) + count = len(self.probe.model.requests) + result = self.call("_session/rewind", request) + require(result.get("rewound") is True, result) + require(result.get("sessionId", sid) == sid, result) + require(len(self.probe.model.requests) == count, "Rewind resent a prompt") + return result + + def stop(self): + if self.closed: + return + self.closed = True + forced = False + try: + self.p.stdin.close() + try: + self.p.wait(timeout=8) + except subprocess.TimeoutExpired: + forced = True + if os.name == "nt": + subprocess.run(["taskkill", "/PID", str(self.p.pid), "/T", "/F"], + env=self.probe.env, capture_output=True, timeout=6, creationflags=FLAGS) + else: + os.killpg(self.p.pid, signal.SIGKILL) + self.p.wait(timeout=5) + self.reader_thread.join(timeout=2) + finally: + self.stderr.close() + self.probe.exits.append({"client": self.number, "pid": self.p.pid, + "exitCode": self.p.returncode, "forced": forced}) + require(not forced and self.p.returncode == 0, f"ACP did not shut down cleanly: {self.probe.exits[-1]}") + # Confirm the old provider exited before opening a replacement ACP + # process. A later final check alone could miss overlapping providers. + entries = [json.loads(line) for line in (self.probe.run / "spawns.jsonl").read_text(encoding="utf8").splitlines()] + owned = {self.p.pid} + while True: + children = {entry.get("pid") for entry in entries + if entry["event"] == "spawn" and entry.get("parentPid") in owned} + expanded = owned | (children - {None}) + if expanded == owned: + break + owned = expanded + native = [entry for entry in entries if entry["event"] == "spawn" and entry.get("pid") in owned + and Path(entry["file"]).resolve() == Path(self.probe.meta["native"]).resolve()] + exited = {entry["pid"] for entry in entries if entry["event"] == "exit"} + require(len(native) == 1 and native[0]["pid"] in exited, "Owned native must exit before cold restart") + + +class Probe: + def __init__(self, manifest): + self.meta = json.loads(manifest.read_text(encoding="utf8")) + self.repo, self.run = Path(self.meta["repo"]), Path(self.meta["run"]) + self.home, self.work = self.run / "home", self.run / "workspace" + self.clients, self.exits, self.steps, self.client_requests = [], [], [], [] + self.model = Model(self.meta.get("wrongReply", False)) + self.env = dict(os.environ) # Launcher already replaced, not merged, the user's environment. + # Narrow runtime PATH to required programs and OS utilities. + self.git = shutil.which("git") + paths = [str(Path(self.meta["node"]).parent)] + if self.git: + paths.append(str(Path(self.git).parent)) + paths += [str(Path(os.environ["SYSTEMROOT"]) / "System32")] if os.name == "nt" else ["/usr/bin", "/bin"] + self.env["PATH"] = os.pathsep.join(dict.fromkeys(paths)) + proxy = f"http://127.0.0.1:{self.model.proxy.server_port}" + for key in ("HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"): + self.env[key] = proxy + self.env.update(NO_PROXY="127.0.0.1,localhost,::1", no_proxy="127.0.0.1,localhost,::1") + provider = {"name": "Native E2E loopback", "base_url": f"http://127.0.0.1:{self.model.server.server_port}/v1", + "wire_api": "responses", "requires_openai_auth": False, + "request_max_retries": 0, "stream_max_retries": 0} + # Select installed model metadata; all responses come from loopback. + config = {"model": "gpt-5.5", "model_provider": "mock", "model_providers": {"mock": provider}, + "approval_policy": "never", "sandbox_mode": "danger-full-access", + "features": {"shell_snapshot": False, "remote_models": False, "plugins": False, "code_mode": False}} + self.env.update(CODEX_PATH=self.meta["native"], MODEL_PROVIDER="mock", CODEX_CONFIG=json.dumps(config), + INITIAL_AGENT_MODE="agent-full-access", NO_BROWSER="1", + NODE_OPTIONS='--require=' + json.dumps(str(self.repo / "scripts/native-e2e/spawn-audit.cjs")), + NATIVE_E2E_SPAWN_LOG=str(self.run / "spawns.jsonl")) + (self.home / "config.toml").write_text( + 'model = "gpt-5.5"\nmodel_provider = "mock"\napproval_policy = "never"\nsandbox_mode = "danger-full-access"\n' + '[features]\nshell_snapshot = false\nremote_models = false\nplugins = false\ncode_mode = false\n' + '[model_providers.mock]\n' + "\n".join(f"{key} = {json.dumps(value)}" for key, value in provider.items()) + "\n", + encoding="utf8") + self.rpc = None + + def step(self, name, **details): + self.steps.append({"name": name, "passed": True, **details}) + print("PASS " + name, flush=True) + + def start(self): + self.rpc = Rpc(self) + return self.rpc + + def restart(self): + self.rpc.stop() + self.verify_build() + return self.start() + + def verify_build(self): + require(sha(self.repo / "dist/index.js") == self.meta["distSha256"], "Build changed during probe; rerun") + require(sha(self.meta["native"]) == self.meta["nativeSha256"], "Native changed during probe; rerun") + current_names = {str(file.relative_to(self.repo)) for file in (self.repo / "src").rglob("*") if file.is_file()} + current_names.update(["package.json", "package-lock.json", "build.mjs"]) + require(current_names == set(self.meta["sources"]), "Source file set changed during probe; rerun") + for name, expected in self.meta["sources"].items(): + require(sha(self.repo / name) == expected, f"Source changed during probe: {name}; rerun") + + def context(self, present, absent=()): + body = json.dumps(self.model.requests[-1]["body"].get("input", [])) + require(all(text in body for text in present), f"Missing model context: {present}") + require(all(text not in body for text in absent), f"Discarded context sent to model: {absent}") + return body + + def history(self): + c = self.start() + capabilities = c.init.get("_meta", {}).get("jetbrains", {}).get("air", {}).get("capabilities", []) + require("sessionRewind" in capabilities, "AIR rewind capability not advertised") + sid = c.new() + for text in ("KEEP_FIRST", "DROP_SECOND", "DROP_THIRD"): + c.prompt(sid, text) + updates = c.load(sid) + users, agents = points(updates), points(updates, "agent_message_chunk") + require([u["text"] for u in users] == ["KEEP_FIRST", "DROP_SECOND", "DROP_THIRD"], users) + self.step("native_history_identities", sessionId=sid, users=users) + bad = {**point(users[1]), "messageFingerprint": "sha256:" + "0" * 64} + count = len(self.model.requests) + for request in ({"sessionId": sid, "beforeMessage": bad}, + {"sessionId": "unknown-session", "beforeMessage": point(users[1])}, + {"sessionId": sid, "beforeMessage": {**point(users[1]), "messageId": "missing-id", "messageFingerprint": bad["messageFingerprint"]}}): + result = c.raw("_session/rewind", request) + require("error" in result, result) + require(points(c.load(sid)) == users and len(self.model.requests) == count, "Bad target mutated history") + self.step("bad_targets_preserve_history_and_do_not_resend") + c.rewind(sid, users[1], agents[0]) + c = self.restart() + retained = points(c.load(sid)) + require([u["text"] for u in retained] == ["KEEP_FIRST"], retained) + require(len(self.model.requests) == count, "Historical cold restart resent prompt") + self.step("historical_same_id_cold_restart_no_resend", sessionId=sid, retained=retained) + c.prompt(sid, "EDITED_SECOND") + self.context(["KEEP_FIRST", "EDITED_SECOND"], ["DROP_SECOND", "DROP_THIRD"]) + self.step("historical_resend_model_context") + updates = c.load(sid) + users, agents = points(updates), points(updates, "agent_message_chunk") + count = len(self.model.requests) + stale_latest = point(users[-1]) + c.rewind(sid, users[-1], agents[-2]) + c = self.restart() + require([u["text"] for u in points(c.load(sid))] == ["KEEP_FIRST"] and len(self.model.requests) == count, + "Latest rewind/cold restart did not retain exactly the prefix without resend") + self.step("latest_same_id_cold_restart_no_resend", sessionId=sid) + stale = c.raw("_session/rewind", {"sessionId": sid, "beforeMessage": stale_latest}) + require("error" in stale, stale) + require([u["text"] for u in points(c.load(sid))] == ["KEEP_FIRST"] and len(self.model.requests) == count, + "Stale removed target mutated/resubmitted history") + self.step("stale_removed_target_rejected_after_cold_restart") + c.prompt(sid, "EDITED_LATEST") + self.context(["KEEP_FIRST", "EDITED_LATEST"], ["EDITED_SECOND", "DROP_SECOND", "DROP_THIRD"]) + self.step("latest_resend_model_context") + users = points(c.load(sid)) + count = len(self.model.requests) + c.rewind(sid, users[0]) + c = self.restart() + require(points(c.load(sid)) == [] and len(self.model.requests) == count, "First rewind retained/resubmitted history") + self.step("first_same_id_cold_restart_no_resend", sessionId=sid) + c.prompt(sid, "EDITED_FIRST") + self.context(["EDITED_FIRST"], ["KEEP_FIRST", "EDITED_SECOND", "EDITED_LATEST", "DROP_SECOND", "DROP_THIRD"]) + self.step("first_resend_model_context") + target = points(c.load(sid))[0] + self.model.seen.clear() + self.model.release.clear() + self.model.arm("hold") + prompt_id = c.send("session/prompt", {"sessionId": sid, "prompt": [{"type": "text", "text": "ACTIVE_TURN"}]}) + require(self.model.seen.wait(10), "Active prompt did not reach local model") + c.rewind(sid, target) + interrupted = c.response(prompt_id) + require(interrupted.get("result", {}).get("stopReason") == "cancelled", interrupted) + self.model.release.set() + self.model.check() + c = self.restart() + require(points(c.load(sid)) == [], "Busy rewind did not persist empty history") + c.prompt(sid, "AFTER_CANCEL") + self.context(["AFTER_CANCEL"], ["ACTIVE_TURN", "EDITED_FIRST"]) + self.step("active_rewind_cancels_native_turn_and_cold_resumes_same_id") + # Repeated text must use occurrence/native identity, not substring matching. + c.prompt(sid, "REPEATED_TARGET") + c.prompt(sid, "REPEATED_TARGET") + updates = c.load(sid) + users, agents = points(updates), points(updates, "agent_message_chunk") + c.rewind(sid, users[-1], agents[-2]) + c = self.restart() + require([u["text"] for u in points(c.load(sid))] == ["AFTER_CANCEL", "REPEATED_TARGET"], "Repeated target boundary lost") + c.prompt(sid, "REPLACED_DUPLICATE") + require(self.context(["AFTER_CANCEL", "REPEATED_TARGET", "REPLACED_DUPLICATE"]).count("REPEATED_TARGET") == 1, + "Duplicate target survived rewind") + self.step("duplicate_exact_identity_cold_restart_resend") + self.model.seen.clear() + self.model.release.clear() + self.model.arm("hold") + prompt_id = c.send("session/prompt", {"sessionId": sid, "prompt": [{"type": "text", "text": "EXPLICIT_CANCEL"}]}) + require(self.model.seen.wait(10), "Cancel fixture did not reach model") + c.notify("session/cancel", {"sessionId": sid}) + interrupted = c.response(prompt_id) + require(interrupted.get("result", {}).get("stopReason") == "cancelled", interrupted) + self.model.release.set() + c.prompt(sid, "AFTER_EXPLICIT_CANCEL") + self.context(["AFTER_EXPLICIT_CANCEL"]) + self.step("explicit_cancel_and_same_session_continue") + + def run_all(self): + report = {"success": False, "build": self.meta, "steps": self.steps} + try: + self.verify_build() + self.history() + self.rpc.stop() + self.verify_build() + self.model.check() + require(not self.client_requests, "Native unexpectedly requested host/client tools") + entries = [json.loads(line) for line in (self.run / "spawns.jsonl").read_text(encoding="utf8").splitlines()] + spawns = [entry for entry in entries if entry["event"] == "spawn"] + exits = {entry["pid"] for entry in entries if entry["event"] == "exit"} + native = [entry for entry in spawns if Path(entry["file"]).resolve() == Path(self.meta["native"]).resolve()] + require(len(native) == len(self.clients), "Each ACP process must launch the resolved real native binary once") + require(all(entry.get("pid") in exits for entry in native), "Native child did not exit before cold restart/cleanup") + if os.name == "nt": + require(all(entry.get("windowsHide") is True for entry in spawns), "Runtime spawn missing windowsHide: inspect spawns.jsonl") + require(not any(entry.get("shell") for entry in native), "Direct native executable must not use a shell") + self.step("native_identity_windowsHide_and_clean_process_shutdown", clients=len(self.clients), spawnCount=len(spawns)) + report["success"] = True + except Exception as error: + report["error"] = {"type": type(error).__name__, "message": str(error)} + (self.run / "failure.txt").write_text(traceback.format_exc(), encoding="utf8") + finally: + self.model.release.set() + for client in self.clients: + try: + client.stop() + except Exception as error: + report["success"] = False + report.setdefault("cleanupErrors", []).append(str(error)) + self.model.close() + try: + self.verify_build() + except Exception as error: + report["success"] = False + report["finalBuildError"] = str(error) + if self.model.errors or not self.model.pending.empty(): + report["success"] = False + report.setdefault("error", {"type": "ModelFixtureError", "message": "Late or missing model request; inspect providerErrors"}) + report.update(modelRequests=len(self.model.requests), titleRequests=len(self.model.title_requests), providerErrors=self.model.errors, + wrongReplyInjected=self.model.injected, + blockedExternalRequests=self.model.blocked, processExits=self.exits, + clientRequests=self.client_requests, + networkBoundary="Loopback provider and deny HTTP(S) proxy; not an OS egress firewall", + harnessSha256=sha(__file__)) + save(self.run / "rpc-traces.json", [client.trace for client in self.clients]) + save(self.run / "model-requests.json", self.model.requests) + save(self.run / "title-requests.json", self.model.title_requests) + save(self.run / "report.json", report) + print(json.dumps({"success": report["success"], "report": str(self.run / "report.json"), + "steps": len(self.steps), "error": report.get("error")}), flush=True) + return 0 if report["success"] else 1 + + +if __name__ == "__main__": + require(len(sys.argv) == 2, "Use npm run test:native:rewind (a fresh build manifest is required)") + raise SystemExit(Probe(Path(sys.argv[1])).run_all()) diff --git a/scripts/native-e2e/spawn-audit.cjs b/scripts/native-e2e/spawn-audit.cjs new file mode 100644 index 000000000..c7109e4e9 --- /dev/null +++ b/scripts/native-e2e/spawn-audit.cjs @@ -0,0 +1,13 @@ +// Observe runtime spawn options without changing them or replacing the process. +const cp = require('node:child_process'); +const { appendFileSync } = require('node:fs'); +const { syncBuiltinESMExports } = require('node:module'); +const spawn = cp.spawn; +const record = (entry) => appendFileSync(process.env.NATIVE_E2E_SPAWN_LOG, JSON.stringify(entry) + '\n'); +cp.spawn = function (file, args, options) { + const child = spawn.apply(this, arguments); + record({ event: 'spawn', file, args, parentPid: process.pid, pid: child.pid, windowsHide: options?.windowsHide, shell: options?.shell }); + child.once('exit', (code, signal) => record({ event: 'exit', pid: child.pid, code, signal })); + return child; +}; +syncBuiltinESMExports(); diff --git a/src/CodexJsonRpcConnection.ts b/src/CodexJsonRpcConnection.ts index 207f075d9..701b36627 100644 --- a/src/CodexJsonRpcConnection.ts +++ b/src/CodexJsonRpcConnection.ts @@ -25,11 +25,11 @@ export function startCodexConnection( if (codexPath) { // cross-spawn runs `.cmd` shims through cmd.exe with correct escaping for the TOML hook override. codex = process.platform === "win32" - ? crossSpawn(codexPath, appServerStartupArgs, {env: spawnEnv}) as ChildProcessWithoutNullStreams - : spawn(codexPath, appServerStartupArgs, {env: spawnEnv}); + ? crossSpawn(codexPath, appServerStartupArgs, {env: spawnEnv, windowsHide: true}) as ChildProcessWithoutNullStreams + : spawn(codexPath, appServerStartupArgs, {env: spawnEnv, windowsHide: true}); } else { const bundledCodexPath = require.resolve("@openai/codex/bin/codex.js"); - codex = spawn(process.execPath, [bundledCodexPath, ...appServerStartupArgs], {env: spawnEnv}); + codex = spawn(process.execPath, [bundledCodexPath, ...appServerStartupArgs], {env: spawnEnv, windowsHide: true}); } attachLogs(codex); From 604a9e3b2d37d84ae50c271c59ccd386361b7883 Mon Sep 17 00:00:00 2001 From: Wolf-L <50284298+Wolf-L@users.noreply.github.com> Date: Fri, 9 Oct 2026 07:48:10 +0800 Subject: [PATCH 8/8] feat: add scoped native session extensions after conversation rewind Build on review/native-rewind-core-codex. Preserve the tested full implementation from eec34bdacac1928500528a68a5e0f937680ece77 with the same tree, while separating the core rewind review from runtime and file/session extensions. --- .github/workflows/ci.yml | 23 -- .github/workflows/compiled-process-smoke.yml | 29 ++ .github/workflows/native-e2e.yml | 43 +++ README.md | 2 + docs/native-session-extensions.md | 41 +++ docs/native-session-extensions.zh-CN.md | 33 ++ docs/session-rewind-extension.md | 33 +- package.json | 5 +- readme-dev.md | 81 ++++- scripts/CodexProcessTermination-smoke.mjs | 47 +++ scripts/native-e2e.mjs | 6 +- scripts/native-e2e/probe.py | 151 ++++++++- src/AcpExtensions.ts | 3 +- src/CodexAcpClient.ts | 21 ++ src/CodexAcpServer.ts | 246 +++++++++++++- src/CodexAppServerClient.ts | 47 +++ src/CodexJsonRpcConnection.ts | 97 +++++- src/CodexProcessHelpers.ts | 88 +++++ src/DesktopAttachmentHistory.ts | 2 +- src/SessionArchive.ts | 7 + src/SessionDiscovery.ts | 73 +++++ src/SessionFileRevert.ts | 300 ++++++++++++++++++ src/SessionQueue.ts | 191 +++++++++++ src/SessionRuntime.ts | 119 +++++++ .../CodexACPAgent/CodexAcpClient.test.ts | 7 +- .../CodexACPAgent/initialize.test.ts | 14 + .../session-rewind-lifecycle.test.ts | 117 ++++++- .../CodexProcessTermination.failures.test.ts | 152 +++++++++ src/__tests__/CodexProcessTermination.test.ts | 176 ++++++++++ src/__tests__/SessionDiscovery.test.ts | 31 ++ src/__tests__/SessionFileRevert.test.ts | 175 ++++++++++ src/__tests__/SessionQueue.test.ts | 196 ++++++++++++ src/__tests__/SessionRuntime.test.ts | 51 +++ .../fixtures/CodexProcessTermination.cjs | 48 +++ .../CodexProcessTermination.compiled.ts | 90 ++++++ .../CodexProcessTermination.entrypoint.cjs | 43 +++ src/__tests__/scenarios/baseline.ts | 14 + .../air/agent-message-and-reasoning.jsonl | 2 +- .../data/air/background-terminal.jsonl | 2 +- .../scenarios/data/air/collab-agent.jsonl | 2 +- .../scenarios/data/air/collab-controls.jsonl | 2 +- .../scenarios/data/air/command-approval.jsonl | 2 +- .../scenarios/data/air/command-failed.jsonl | 2 +- .../data/air/command-output-stdin.jsonl | 2 +- .../air/command-without-streamed-output.jsonl | 2 +- .../data/air/context-compaction.jsonl | 2 +- .../scenarios/data/air/dynamic-tool.jsonl | 2 +- .../data/air/file-change-approval.jsonl | 2 +- .../scenarios/data/air/file-changes.jsonl | 2 +- .../data/air/fuzzy-file-search.jsonl | 2 +- .../scenarios/data/air/goal-update.jsonl | 2 +- .../scenarios/data/air/guardian-review.jsonl | 2 +- .../scenarios/data/air/history-replay.jsonl | 2 +- .../scenarios/data/air/image-generation.jsonl | 2 +- .../scenarios/data/air/image-view.jsonl | 2 +- .../data/air/late-subagent-update.jsonl | 2 +- .../scenarios/data/air/mcp-elicitation.jsonl | 2 +- .../data/air/mcp-startup-failure.jsonl | 2 +- .../data/air/mcp-tool-approval.jsonl | 2 +- .../scenarios/data/air/mcp-tool.jsonl | 2 +- .../data/air/mcp-url-elicitation.jsonl | 2 +- .../data/air/native-subagent-session.jsonl | 2 +- .../data/air/nested-subagent-session.jsonl | 2 +- .../air/network-and-sandbox-approval.jsonl | 2 +- .../data/air/plan-deltas-and-turn-plan.jsonl | 2 +- .../data/air/plan-review-permission.jsonl | 2 +- .../scenarios/data/air/plan-stream.jsonl | 2 +- .../scenarios/data/air/read-search-list.jsonl | 2 +- .../data/air/subagent-activity.jsonl | 2 +- .../scenarios/data/air/web-search.jsonl | 2 +- src/__tests__/scenarios/scenario-harness.ts | 9 +- src/index.ts | 63 ++-- .../reporters/FuzzySearchReporter.ts | 2 +- 73 files changed, 2781 insertions(+), 161 deletions(-) create mode 100644 .github/workflows/compiled-process-smoke.yml create mode 100644 .github/workflows/native-e2e.yml create mode 100644 docs/native-session-extensions.md create mode 100644 docs/native-session-extensions.zh-CN.md create mode 100644 scripts/CodexProcessTermination-smoke.mjs create mode 100644 src/CodexProcessHelpers.ts create mode 100644 src/SessionArchive.ts create mode 100644 src/SessionDiscovery.ts create mode 100644 src/SessionFileRevert.ts create mode 100644 src/SessionQueue.ts create mode 100644 src/SessionRuntime.ts create mode 100644 src/__tests__/CodexProcessTermination.failures.test.ts create mode 100644 src/__tests__/CodexProcessTermination.test.ts create mode 100644 src/__tests__/SessionDiscovery.test.ts create mode 100644 src/__tests__/SessionFileRevert.test.ts create mode 100644 src/__tests__/SessionQueue.test.ts create mode 100644 src/__tests__/SessionRuntime.test.ts create mode 100644 src/__tests__/fixtures/CodexProcessTermination.cjs create mode 100644 src/__tests__/fixtures/CodexProcessTermination.compiled.ts create mode 100644 src/__tests__/fixtures/CodexProcessTermination.entrypoint.cjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b89f0a1fb..fc655efd6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,26 +29,3 @@ jobs: run: npm test - name: Bundle binaries run: npm run bundle:all - - native-rewind: - name: Native rewind (${{ matrix.os }}) - runs-on: ${{ matrix.os }} - permissions: - contents: read - timeout-minutes: 10 - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, windows-latest] - steps: - - uses: actions/checkout@v7 - - uses: actions/setup-node@v7 - with: - node-version: "24" - - uses: actions/setup-python@v6 - with: - python-version: "3.12" - - name: Install dependencies including the native binary - run: npm ci --include=optional - - name: Run isolated native conversation rewind - run: npm run test:native:rewind diff --git a/.github/workflows/compiled-process-smoke.yml b/.github/workflows/compiled-process-smoke.yml new file mode 100644 index 000000000..9e7598bb9 --- /dev/null +++ b/.github/workflows/compiled-process-smoke.yml @@ -0,0 +1,29 @@ +name: Compiled POSIX process smoke + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +permissions: {} + +jobs: + compiled-process-smoke: + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: '24' + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 + with: + bun-version: 1.3.11 + - run: npm ci + - name: Compile and exercise native override without Node on child PATH + timeout-minutes: 3 + run: npm run test:compiled-process diff --git a/.github/workflows/native-e2e.yml b/.github/workflows/native-e2e.yml new file mode 100644 index 000000000..d9a8ffd8c --- /dev/null +++ b/.github/workflows/native-e2e.yml @@ -0,0 +1,43 @@ +name: Native loopback E2E + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +permissions: {} + +jobs: + native-e2e: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 10 + permissions: + contents: read + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: '24' + - uses: actions/setup-python@v6 + with: + python-version: '3.13' + - name: Install locked dependencies (including native optional package) + run: npm ci + - name: Fresh build and real native stdio tests with loopback model + timeout-minutes: 5 + run: npm run test:native + - name: Preserve build hashes, RPC, model, spawn and failure evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: native-e2e-${{ matrix.os }} + path: tmp/native-e2e/ + include-hidden-files: true + if-no-files-found: warn + retention-days: 7 diff --git a/README.md b/README.md index b87caed3c..c1398636e 100644 --- a/README.md +++ b/README.md @@ -108,3 +108,5 @@ See [AIR extensions](docs/air-extensions.md#async-tasks) for the capability, lif ## License By contributing, you agree that your contributions will be licensed under the Apache 2.0 License. + +Local native-session test extensions: [English](docs/native-session-extensions.md) | [简体中文](docs/native-session-extensions.zh-CN.md). diff --git a/docs/native-session-extensions.md b/docs/native-session-extensions.md new file mode 100644 index 000000000..c2eac096e --- /dev/null +++ b/docs/native-session-extensions.md @@ -0,0 +1,41 @@ +# Local native session extensions + +This local test build integrates PR #508 on main `51aa211` (adapter 2.1.1, locked Codex 0.160.1, ACP SDK 1.5.0). It preserves current-main hooks, attachments, tool-call reports and compaction handling. No Codeg changes, global installation, push or new PR are included. + +## Negotiated operations + +Read the `initialize` metadata before invoking any extension. These are named methods with strict schemas, not a generic native RPC tunnel. + +| Capability | Method | Contract | +|---|---|---| +| AIR `sessionRewind` | `_session/rewind` | Same ID, exclusive `beforeMessage`, optional `resumeAtMessage`; `{rewound:true}` only after persisted-prefix verification. | +| `_meta.steering.idleBehavior` | `_session/steering` | Opt in with `_meta.steering.idleBehavior:"promptRequired"`; idle returns `{outcome:"promptRequired"}` without starting a model request. Non-opted clients retain the existing idle new-turn behavior. | +| `_meta.runtime` | `_session/runtime/read` | `resource`: context, usage, mcp, commands, plugins. Unknown measurements stay null. Commands are native skills; this is not a list of every Desktop UI command. | +| `_meta.runtime` | `_session/runtime/control` | `action`: reloadSkills, reconnectMcp, reloadPlugins. Skill reload republishes available commands. MCP/plugins are provider-wide idle operations; their acknowledgement is not runtime readiness. | +| `_meta.archive` | `_session/archive`, `_session/unarchive` | `{sessionId}`; reversible native archive, same ID. Existing standard session/delete keeps its prior archive-backed behavior. No permanent delete. Archive closes local routing; unarchive requires load to reopen. | +| `_meta.discovery` | `_session/search` | Bounded searchTerm/cursor/limit/archived. Searches user sources appServer/cli/vscode. Current native search can miss text retained only through ancestor rollout lineage after rewind. | +| `_meta.discovery` | `_session/attachments` | list/add/remove native metadata only. Does not create/delete a file, worktree, PR or prompt attachment. Loaded-session ownership required. | +| `_meta.queue` | `_session/queue` | Only list is advertised after actual-native version and positive nonmutating method probe. Writes/start return unsupported before native mutation. Pending native queues prevent ACP load/resume to avoid unowned auto-dispatch. | +| `_meta.fileRevert` | `_session/files/revert` | `{sessionId,toolCallId,dryRun,previewToken?}`; reverse one completed native fileChange tool's recorded text patches in Git. Preview token required for apply. History stays unchanged. | + +## File reversal + +Preview and commit re-read native history and fingerprint affected files. A stale preview, conflict, unknown change kind, binary/unsupported patch or unsafe path fails closed for the entire batch. Git uses reverse check/apply without --index/--cached/--3way, preserving the user's index and unrelated bytes. Reverted text follows Git attributes/autocrlf and is not promised to be byte-identical to its original encoding/newlines. Shell changes, unrecorded subagents and whole-workspace checkpoints are outside coverage. + +The server reserves the provider while validating loaded native threads and background terminals. External editors and unrelated processes are not controlled by this fence; Git conflict checks and fresh fingerprints narrow but do not eliminate an OS-level race. + +## Timeouts and recovery + +POSIX supervision uses the current runtime and adds no external Node dependency. Compiled Bun executables re-enter fixed built-in supervisor/guardian modes before importing the ACP application or reading configuration/log settings; these modes require private pipe descriptors and accept no executable source. Node and direct Bun runs execute the same built-in helper implementation. The two small helpers preserve the provider's exit status and perform cleanup from within the owned group; they do not interpret ACP traffic. stdin EOF and stdout/stderr use inherited descriptors, common termination signals are forwarded, and the extra control pipe is unreferenced in the adapter. A POSIX provider spawn error is reported on stderr with supervisor exit code 1. Orphaned processes must be reaped by the host init (use --init in containers); unreaped groups can time out rather than be reported stopped. Compiled native CODEX_PATH launch is tested with Node absent from PATH; this does not establish compatibility of the separate bundled JavaScript launcher path in compiled distributions. + +Runtime mutations have a 30-second deadline. Timeout invalidates local sessions and attempts to terminate the owned provider; reconnect the ACP adapter and explicitly load a session. No mutation is replayed. POSIX launches the provider (including the default Node launcher and native child) in a dedicated process group with a guardian. A private control pipe requests SIGKILL from inside that group; launcher/supervisor exit or adapter pipe closure also triggers cleanup. The adapter never sends a destructive signal to a remembered POSIX PID or PGID. Termination succeeds only after the supervisor exits and the group disappears; an unverifiable group or a five-second deadline is a failure, not a stopped-provider result. Processes that deliberately leave the group are outside this guarantee. Windows retains taskkill /T /F for a live owned launcher; if the launcher has already exited, tree ownership can no longer be verified and termination fails without targeting its old PID. Repeated termination calls share the same result, including failures. Read timeouts return unavailable. A history mutation with an uncertain outcome blocks further sends until successful load/reconciliation. + +## Desktop parity boundaries + +Supported and locally exercised: same-ID first/latest/historical/repeated rewind, cancel-and-settle, cold load, resend context truncation, runtime status/control, archive/unarchive, attachment metadata persistence, safe idle steering, native search of current rollout, queue inspection and per-tool text patch reversal. + +Not claimed: full native queue automatic-turn adoption, realtime audio/voice, timeline API, complete Desktop project/worktree/UI management, whole-session file checkpoint restore, binary reverse patches, all child process/background writer isolation, or complete search over retained ancestor rollouts. A native schema method does not establish an implemented ACP handler. + +## Validation + +Full suite: 82 test files passed, 8 skipped; 1342 tests passed, 39 skipped on Windows. Typecheck/build passed. Additional file/lifecycle regression: 28 passed. Isolated ACP plus native plus localhost mock model covered 0.160.1 and CODEX_PATH 0.159.3; no real model or real user session was used. Independent focused review passed 6/6 reproductions, including real owned-child termination. The native file RPC E2E passed 9 checks (actual apply_patch, persisted replay, preview, stale/conflict refusal, index/unrelated-byte preservation and same-session continuation). These are scoped findings, not proof of every interleaving. diff --git a/docs/native-session-extensions.zh-CN.md b/docs/native-session-extensions.zh-CN.md new file mode 100644 index 000000000..b5cb26092 --- /dev/null +++ b/docs/native-session-extensions.zh-CN.md @@ -0,0 +1,33 @@ +# 本地原生会话扩展 + +本测试版在 main `51aa211` 上整合 PR #508,adapter 2.1.1,冻结 Codex 0.160.1、SDK 1.5.0。保留主线 hooks、附件回放、工具调用协议与压缩处理。没有改 Codeg、全局安装、push 或新 PR。 + +## 已接入 + +- `_session/rewind`:保留原接口和作者提交;同 ID 回退首条、历史、最近消息。验证指纹/保留边界,生成中先取消并等待旧 prompt 清理,再调用原生回退并核验持久前缀。 +- `_session/steering`:opt-in `_meta.steering.idleBehavior:"promptRequired"`,空闲返回 promptRequired,不偷偷启动模型。未 opt-in 保留旧行为。 +- runtime read:context、usage、mcp、commands(native skills)、plugins。未知用量保留 null,不捏造分类。 +- runtime control:reloadSkills、reconnectMcp、reloadPlugins;后两者要求 provider 空闲,确认回执不等于 runtimeReady。技能刷新后重发可用命令。 +- archive/unarchive:原生可逆归档、同 ID;不新增永久删除。旧 session/delete 仍保持原来的 archive 语义。 +- search:限定参数和 appServer/cli/vscode 来源;已验证当前 rollout synthetic 文本命中。回退后仅保留在祖先 rollout 的文本可能被原生搜索漏掉。 +- attachments:原生 thread 元数据 list/add/remove、冷重启持久化;不创建/删除磁盘文件、PR 或 worktree。 +- queue:只在真实 native 版本和无副作用探测通过后广告 list。写入/start 在 native mutation 前 unsupported;已有 durable pending queue 阻止 ACP load/resume,避免无人归属的自动生成。 +- files/revert:限定单个已完成 native fileChange 工具的 Git 文本补丁。preview token 绑定原生补丁与文件指纹,提交前复核;不改会话历史。 + +## 文件语义与恢复 + +文件撤销不等于 whole checkpoint:不覆盖 shell、未记录子代理、二进制补丁。Git reverse check/apply 不用 --index/--cached/--3way;用户 index 和无关文件严格保持。恢复后的文本走 Git 属性/autocrlf,不保证原始字节或换行完全一致。未知 change kind、缺失 diff、冲突、陈旧 token、越界/链接路径整批拒绝。 + +runtime mutation 30 秒超时后使会话失效并尝试终止 provider,用户重连 ACP 再显式 load;不会自动重放 mutation。POSIX 将 provider(包括默认 Node launcher 及 native 子进程)置于独立进程组,通过私有控制管道请求组内 guardian 执行 SIGKILL;launcher/supervisor 退出或适配器管道关闭也触发清理。适配器不会向保存的 POSIX PID/PGID 发破坏性信号。只有 supervisor 已退出且组已消失才报告终止成功;无法确认或五秒超时均报失败。主动脱离该组的进程不在保证内。Windows 对仍存活的 owned launcher 使用 taskkill /T /F;launcher 已退出时无法再确认整树归属,明确报错且不针对旧 PID 清树。重复关闭复用同一结果,包括失败。读取超时返回 unavailable。历史回退 dispatch 后失败可能已改变磁盘,必须成功 load 对账再继续发送。 + +POSIX 监督复用当前运行时,不新增外部 Node 依赖。Bun 编译产物通过固定内置 supervisor/guardian 模式重入,在加载 ACP 模块或读取配置/日志设置前分发;模式要求私有管道描述符,不接收可执行源码。Node 和直接 Bun 运行使用同一份内置 helper 实现。两个小型 helper 分别负责保留 provider 退出状态和从 owned 组内部清理,不解释 ACP 消息;stdin EOF 和 stdout/stderr 使用继承的文件描述符,常规终止信号转发给 provider,额外控制管道在适配器中 unref。POSIX provider 启动失败通过 stderr 和 supervisor 退出码 1 报告。宿主 init 必须回收孤儿进程(容器使用 --init);未回收的组可能超时报错,不虚报已停止。compiled native CODEX_PATH 路径已在 PATH 不含 Node 的条件下验证;这不代表另一个 bundled JavaScript launcher 路径在 compiled 发行物中的兼容性已得到验证。 + +## 尚未实现的 Desktop 能力 + +完整队列自动回合 observer/adoption、realtime voice、timeline、Desktop 项目/worktree/UI 全部管理、全工作区文件 checkpoint、二进制反向恢复、外部写入进程完全隔离、祖先 rollout 全文搜索均不宣称完成。native schema 存在某方法不等于本 ACP 已支持。 + +## 验证 + +Windows 完整套件 82 文件通过/8 跳过,1342 项通过/39 跳过;typecheck/build 通过;文件/lifecycle 增量 28 项通过。0.160.1 与 CODEX_PATH 0.159.3 的真实 ACP+native+localhost mock 模型隔离实验通过,未调用真实模型或用户会话。独立定向 review 六项复现全通过(含真实 owned-child 终止);独立文件 RPC E2E 九项通过,覆盖真实 apply_patch、持久回放、预览、陈旧/冲突拒绝、index/无关文件字节保持、同会话继续;仍不代表所有交错均已验证。 + +协议细节见 [English](native-session-extensions.md);原 rewind 作者 Nikita Ashikhmin,来源 [PR #508](https://github.com/agentclientprotocol/codex-acp/pull/508),四条原提交保留在历史中。 diff --git a/docs/session-rewind-extension.md b/docs/session-rewind-extension.md index 330a8f67e..cd21a7d2f 100644 --- a/docs/session-rewind-extension.md +++ b/docs/session-rewind-extension.md @@ -58,39 +58,10 @@ After a successful response, the client can remove the same transcript suffix an ## Lifecycle and compatibility -An active prompt is cancelled only after the target validates; the adapter waits for prompt cleanup before native mutation. A pending turn/start registration is rejected, because an unacknowledged native start could write after the rewind. Competing lifecycle/settings/steering operations are fenced. Provider replacement waits for an existing rewind. +An active prompt is cancelled only after the target validates; the adapter waits for prompt cleanup before native mutation. A pending turn/start registration is rejected, because an unacknowledged native start could write after the rewind. Competing lifecycle/settings/steering/runtime operations are fenced. Provider replacement waits for an existing rewind. `thread/rollback` is used only when native history explicitly reports `legacy`. It is a separately typed compatibility request for old `CODEX_PATH` binaries, not part of current generated API types. Unknown history mode, arbitrary errors, malformed responses and `thread/revert` failures never trigger rollback. -History rewind does not restore files. +History rewind does not restore files. See [native session extensions](native-session-extensions.md) for the separate, previewed single-tool Git patch reversal. The interface and original implementation come from [Nikita Ashikhmin's PR #508](https://github.com/agentclientprotocol/codex-acp/pull/508), preserved as four commits in this branch. This local integration merges current main and adds validation/lifecycle safeguards; it is not a new incompatible rewind protocol. - -## Native regression tests - -With Node.js 24+, Python 3 and the platform optional Codex dependency installed, -run `npm run test:native:rewind`. The runner builds this checkout, hashes source, -bundle and the resolved native executable, then launches real ACP/native -processes against a scripted loopback Responses provider. It requires no account -or external model. Only rewind/history/lifecycle scenarios are present; file -restore and other extension scenarios are excluded. - -The suite checks historical, latest and first-message rewind, same-ID cold -restart before resend, invalid and stale targets, exact repeated-message -identity, active-turn interruption, explicit cancellation, retained provider -context, native process identity and clean shutdown. No native transcript is -seeded or rewritten by the test client. The Windows/Linux native CI job runs -this independently of the upstream unit/build job. - -Each run creates an isolated home, workspace, config and temporary directory -under `tmp/native-e2e/run-*`. The child environment excludes inherited -credentials, provider overrides and user profiles. Model traffic is loopback; -an HTTP(S) deny proxy blocks incidental external requests, but is not an OS -network firewall. Artifact directories are retained for diagnosis, including -`build.json`, `report.json`, RPC/model traces and process-exit evidence. -Nonzero exit, missing acknowledgement, changed source/build, failed assertion -or unclean shutdown fails the run. - -Run `node scripts/native-e2e.mjs --rewind-only --wrong-reply` to verify the -failure path: it must exit 1 and record `success:false`. The optional -`--output ` selects an artifact parent inside this checkout only. diff --git a/package.json b/package.json index 8908dfbe3..02e742520 100644 --- a/package.json +++ b/package.json @@ -40,10 +40,11 @@ "release:preflight": "bash scripts/release-preflight.sh", "test": "vitest run --no-file-parallelism --retry=2", "test:e2e": "npm run build && RUN_E2E_TESTS=true vitest run --no-file-parallelism --retry=2 src/__tests__/CodexACPAgent/e2e", + "test:native": "node scripts/native-e2e.mjs", + "test:compiled-process": "node scripts/CodexProcessTermination-smoke.mjs", "test:watch": "vitest", "typecheck": "tsc --noEmit && tsc --noEmit -p examples/tsconfig.json", - "codex-test": "tsx .claude/skills/run-codex/scripts/run-codex-test.ts", - "test:native:rewind": "node scripts/native-e2e.mjs --rewind-only" + "codex-test": "tsx .claude/skills/run-codex/scripts/run-codex-test.ts" }, "homepage": "https://github.com/agentclientprotocol/codex-acp#readme", "bugs": { diff --git a/readme-dev.md b/readme-dev.md index f11b015af..f49192794 100644 --- a/readme-dev.md +++ b/readme-dev.md @@ -63,6 +63,78 @@ Run from binaries } ``` +### Reproduce native stdio E2E without credentials + +Requires Node.js 24+, Python 3, Git, and the platform optional dependency installed +by `npm ci`. Run the same command on Windows or Linux: + +```sh +npm ci +npm run test:native +``` + +For a standalone rewind/core change, use `npm run test:native -- --rewind-only`. +In PowerShell, use `npm.cmd run test:native -- --rewind-only` if `npm.ps1` +consumes the argument, or invoke `node scripts/native-e2e.mjs --rewind-only` +directly. `build.json` records `mode: "rewind-only"` so the selected scope is +verifiable. These entry points all rebuild before running. +This runs first/historical/latest rewind, cold restart with no resend, resend +context, stale/bad targets, duplicate identities and cancellation. It asserts +only the rewind capability; it does not call or require file-revert, runtime or +discovery extensions. Build/environment/process checks still apply. The default +suite includes all rewind checks plus the file checks below. + +`test:native` builds the current checkout before each run. It resolves the native +executable from the installed `@openai/codex` platform package, creates a new +fixture under `tmp/native-e2e/run-*`, and uses real ACP/native stdio processes. +The only fake is a scripted Responses model bound to `127.0.0.1`; it returns the +actual advertised `apply_patch` tool call, which the native process executes. +No account, API key, saved user session, or globally installed Codex is needed. + +The suite checks first-message, historical and latest rewind with the same session ID, +cold restart before resend, actual resend model context, duplicate identities, +bad-target non-mutation, active-turn rewind/cancellation, and explicit cancellation. +It also checks native file-change events and cold replay, read-only preview, +required/stale tokens, conflicts, successful patch reversal, preservation of +independent staged/unstaged/untracked files and exact Git index bytes, and +continuation without changing conversation history. A spawn observer records +the runtime native identity, `windowsHide: true`, and child exits without changing +spawn options. The Windows check requires `windowsHide` on runtime children; +the observer follows Node supervisors on Linux too. No client file/tool shim +stands in for native execution. + +All subprocesses receive an environment whitelist with fresh HOME, CODEX_HOME, +profile/config/cache/temp directories and no inherited credentials. Runtime PATH +is narrowed to Node, Git and OS utilities. HTTP(S) proxies deny non-loopback +requests; the provider needs no authorization. This is an application-level +network boundary, not an OS firewall. The fixture uses full-access mode to make +native text patches portable without requiring a host sandbox installation. +Only synthetic fixture paths are provided to its scripted tool calls. + +Each run writes `report.json`, `build.json` (source/build/native hashes), +`rpc-traces.json`, `model-requests.json`, `title-requests.json`, `spawns.jsonl`, native tool outputs and +stderr logs. Failures return nonzero and retain their evidence. Changes to source, +dist, or the native binary during the run fail verification; finish concurrent +edits first and rerun. There is deliberately no resume-old-fixture/skip-build mode. +The Linux/Windows CI matrix uploads these fresh fixture artifacts even on failure. + +Ephemeral title-generation requests are recognized by their output schema and +title instruction, answered locally and recorded separately. They cannot consume +the expected user-turn request budget or satisfy a no-resend/context assertion. + +Optional knobs (no runtime credentials are passed through): + +- `node scripts/native-e2e.mjs --rewind-only --wrong-reply` deliberately changes + the first loopback assistant response. The normal ACP reply assertion must + fail with `Assistant reply mismatch`, `wrongReplyInjected: true` in the report, + and exit code **1**. This is a negative control, not a passing test command; + it does not force an exit or change production behavior. + +- `npm run test:native -- --output ` creates a unique run under that + artifact parent directory; it never reuses an existing run. +- `NATIVE_E2E_PYTHON` selects a Python 3 executable (default `python` on Windows, + `python3` elsewhere). Set it in the invoking shell if Python is not on PATH. + ### Build binaries Building standalone binaries requires [bun](https://bun.com/docs/installation). @@ -102,12 +174,3 @@ the adapter does not rely on notices being displayed. Command replies, review results, and terminal/retrying errors retain their existing response or failure channels. Clients advertising session compaction support continue to receive the dedicated compaction lifecycle instead of the legacy completion advisory. - -### Isolated native rewind tests - -Run `npm run test:native:rewind` with Node 24+, Python 3, and the platform -optional Codex package installed. `NATIVE_E2E_PYTHON` overrides the Python -executable (default: `python` on Windows, `python3` elsewhere). The harness -uses an isolated child environment and local provider; it does not reuse your -Codex home or credentials. See [rewind regression tests](docs/session-rewind-extension.md#native-regression-tests) -for assertions, retained evidence and the intentional failure-path command. diff --git a/scripts/CodexProcessTermination-smoke.mjs b/scripts/CodexProcessTermination-smoke.mjs new file mode 100644 index 000000000..0c9675297 --- /dev/null +++ b/scripts/CodexProcessTermination-smoke.mjs @@ -0,0 +1,47 @@ +#!/usr/bin/env node +// Linux/macOS: node scripts/CodexProcessTermination-smoke.mjs +// Offline: append --compile-executable-path=/absolute/path/to/bun +// Containers must use --init so killed orphan group members are reaped. +import {spawnSync} from "node:child_process"; +import {mkdtempSync, mkdirSync, rmSync} from "node:fs"; +import {tmpdir} from "node:os"; +import path from "node:path"; +import {fileURLToPath} from "node:url"; + +if (process.platform === "win32") throw new Error("Run this POSIX process-group smoke test on Linux/macOS (containers: --init)"); +const options = process.argv.slice(2); +if (options.length > 1 || options.some(value => !value.startsWith("--compile-executable-path="))) { + throw new Error("Usage: node scripts/CodexProcessTermination-smoke.mjs [--compile-executable-path=/absolute/path/to/bun]"); +} +const root = fileURLToPath(new URL("../", import.meta.url)); +const work = mkdtempSync(path.join(tmpdir(), "CodexProcessTermination-smoke-")); +const noNode = path.join(work, "empty-path"); +mkdirSync(noNode); +// Keep fixture runs isolated from the user's ACP configuration and log settings. +const env = {...process.env, APP_SERVER_LOGS: "", CODEX_CONFIG: "{}", DEFAULT_AUTH_REQUEST: ""}; +function run(command, args, runEnv = env) { + const result = spawnSync(command, args, { + cwd: root, env: runEnv, stdio: "inherit", timeout: 60_000, + killSignal: "SIGKILL", windowsHide: true, + }); + if (result.error) throw result.error; + if (result.status !== 0) throw new Error(`${path.basename(command)} failed: status=${result.status}, signal=${result.signal}`); +} +function compile(source, name) { + const output = path.join(work, name); + // Native host target avoids cross-runtime downloads; an explicit cached Bun + // executable may be supplied for fully offline reproduction. + run("bun", ["build", "--minify", "--compile", ...options, source, "--outfile", output]); + return output; +} +try { + const fixture = compile("src/__tests__/fixtures/CodexProcessTermination.cjs", "native-fixture"); + const transport = compile("src/__tests__/fixtures/CodexProcessTermination.compiled.ts", "transport-smoke"); + const acp = compile("src/index.ts", "codex-acp"); + run(transport, [fixture, "native"], {...env, PATH: noNode}); + run(process.execPath, ["src/__tests__/fixtures/CodexProcessTermination.entrypoint.cjs", acp, fixture], + {...env, PATH: noNode}); + console.log("PASS compiled POSIX smoke: no Node on child PATH; group termination, EOF, timeout and ACP entrypoint verified"); +} finally { + rmSync(work, {recursive: true, force: true}); +} diff --git a/scripts/native-e2e.mjs b/scripts/native-e2e.mjs index 70b9399ec..53f7d0764 100644 --- a/scripts/native-e2e.mjs +++ b/scripts/native-e2e.mjs @@ -14,14 +14,12 @@ for (let i = 0; i < args.length; i++) { else if (args[i] === '--wrong-reply' && !wrongReply) wrongReply = true; else if (args[i] === '--output' && !output && args[i + 1] && !args[i + 1].startsWith('--')) output = args[++i]; else { - console.error('Usage: npm run test:native:rewind -- [--rewind-only] [--wrong-reply] [--output ]'); + console.error('Usage: npm run test:native -- [--rewind-only] [--wrong-reply] [--output ]'); process.exit(2); } } if (Number(process.versions.node.split('.')[0]) < 24) throw new Error('Node 24+ is required'); const parent = path.resolve(output ?? path.join(repo, 'tmp', 'native-e2e')); -const relativeOutput = path.relative(repo, parent); -if (relativeOutput.startsWith('..') || path.isAbsolute(relativeOutput)) throw new Error('Artifact output must stay inside this checkout'); mkdirSync(parent, { recursive: true }); const run = mkdtempSync(path.join(parent, 'run-')); for (const name of ['home', 'workspace', 'tmp', 'home/appdata', 'home/localappdata', 'home/.config', 'home/.cache']) { @@ -78,7 +76,7 @@ try { writeFileSync(path.join(run, 'build.log'), command(process.execPath, [path.join(repo, 'build.mjs')])); if (JSON.stringify(before) !== JSON.stringify(sources())) throw new Error('Sources changed during build; rerun after concurrent edits finish'); const metadata = { - repo, run, mode: 'rewind-only', wrongReply, node: process.execPath, nodeVersion: process.version, native, + repo, run, mode: rewindOnly ? 'rewind-only' : 'full', wrongReply, node: process.execPath, nodeVersion: process.version, native, nativePackage: packageName, codexVersion: installed.version, nativeVersion: command(native, ['--version']), nativeSha256: hash(native), distSha256: hash(path.join(repo, 'dist/index.js')), sources: before, diff --git a/scripts/native-e2e/probe.py b/scripts/native-e2e/probe.py index 89987de96..47fc95d34 100644 --- a/scripts/native-e2e/probe.py +++ b/scripts/native-e2e/probe.py @@ -20,6 +20,8 @@ import traceback +PATCH = "*** Begin Patch\n*** Update File: changed.txt\n@@\n-old line\n+native edited line\n*** End Patch\n" +CALL_ID = "native-e2e-apply-patch" TIMEOUT = 35 FLAGS = getattr(subprocess, "CREATE_NO_WINDOW", 0) @@ -67,6 +69,7 @@ def __init__(self, wrong_reply=False): self.pending = queue.Queue() self.seen, self.release = threading.Event(), threading.Event() self.lock = threading.Lock() + self.tool_route = None owner = self class Deny(http.server.BaseHTTPRequestHandler): @@ -114,15 +117,40 @@ def do_POST(self): owner.seen.set() require(owner.release.wait(TIMEOUT), "Held request was not released") events = [{"type": "response.created", "response": {"id": f"resp-{number}"}}] - answer = '{"title":"Local native fixture"}' if title else f"LOCAL_ANSWER_{number}" - if not title: - owner.expected_answers.append(answer) - if owner.wrong_reply and not owner.injected: - answer = "INJECTED_WRONG_REPLY" - owner.injected = True - events.append({"type": "response.output_item.done", "item": { - "type": "message", "role": "assistant", "id": f"msg-{number}", - "content": [{"type": "output_text", "text": answer}]}}) + if action == "patch": + found = [] + + def inspect(tools, namespace=None): + for tool in tools: + if tool.get("type") == "namespace": + inspect(tool.get("tools", []), tool.get("name")) + elif tool.get("name") == "apply_patch": + found.append((tool, namespace)) + + inspect(body.get("tools", [])) + require(len(found) == 1 and found[0][0].get("type") == "custom", + "Native must advertise one custom apply_patch tool") + tool, namespace = found[0] + owner.tool_route = {"name": tool["name"], "type": tool["type"], "namespace": namespace} + item = {"type": "custom_tool_call", "id": "fc-" + CALL_ID, "call_id": CALL_ID, + "name": "apply_patch", "input": PATCH} + if namespace: + item["namespace"] = namespace + events.extend([ + {"type": "response.output_item.added", "item": {**item, "input": "", "status": "in_progress"}}, + {"type": "response.custom_tool_call_input.delta", "item_id": item["id"], "call_id": CALL_ID, "delta": PATCH}, + {"type": "response.output_item.done", "item": item}, + ]) + else: + answer = '{"title":"Local native fixture"}' if title else f"LOCAL_ANSWER_{number}" + if not title: + owner.expected_answers.append(answer) + if owner.wrong_reply and not owner.injected: + answer = "INJECTED_WRONG_REPLY" + owner.injected = True + events.append({"type": "response.output_item.done", "item": { + "type": "message", "role": "assistant", "id": f"msg-{number}", + "content": [{"type": "output_text", "text": answer}]}}) events.append({"type": "response.completed", "response": { "id": f"resp-{number}", "usage": {"input_tokens": 10, "output_tokens": 2, "total_tokens": 12}}}) data = "".join("data: " + json.dumps(event) + "\n\n" for event in events).encode() @@ -177,7 +205,7 @@ def __init__(self, probe): self.init = self.call("initialize", { "protocolVersion": 1, "clientInfo": {"name": "isolated-native-e2e", "version": "1"}, "clientCapabilities": {"_meta": {"jetbrains": {"air": { - "version": 1, "capabilities": []}}}}}) + "version": 1, "capabilities": [] if probe.meta["mode"] == "rewind-only" else ["diffPatch"]}}}}}) def reader(self): try: @@ -238,8 +266,8 @@ def call(self, method, params): def new(self): return self.call("session/new", {"cwd": str(self.probe.work), "mcpServers": []})["sessionId"] - def prompt(self, sid, text): - self.probe.model.arm("answer") + def prompt(self, sid, text, patch=False): + self.probe.model.arm(*(["patch", "answer"] if patch else ["answer"])) result = self.call("session/prompt", {"sessionId": sid, "prompt": [{"type": "text", "text": text}]}) require(result.get("stopReason") == "end_turn", result) self.probe.model.check() @@ -324,6 +352,7 @@ def __init__(self, manifest): self.env = dict(os.environ) # Launcher already replaced, not merged, the user's environment. # Narrow runtime PATH to required programs and OS utilities. self.git = shutil.which("git") + require(self.git or self.meta["mode"] == "rewind-only", "Git is required for file revert") paths = [str(Path(self.meta["node"]).parent)] if self.git: paths.append(str(Path(self.git).parent)) @@ -336,7 +365,8 @@ def __init__(self, manifest): provider = {"name": "Native E2E loopback", "base_url": f"http://127.0.0.1:{self.model.server.server_port}/v1", "wire_api": "responses", "requires_openai_auth": False, "request_max_retries": 0, "stream_max_retries": 0} - # Select installed model metadata; all responses come from loopback. + # This installed model profile advertises the freeform native apply_patch. + # The profile name selects tool metadata only; no real model is contacted. config = {"model": "gpt-5.5", "model_provider": "mock", "model_providers": {"mock": provider}, "approval_policy": "never", "sandbox_mode": "danger-full-access", "features": {"shell_snapshot": False, "remote_models": False, "plugins": False, "code_mode": False}} @@ -475,11 +505,102 @@ def history(self): self.context(["AFTER_EXPLICIT_CANCEL"]) self.step("explicit_cancel_and_same_session_continue") + def git_command(self, *args): + result = subprocess.run([self.git, *args], cwd=self.work, env=self.env, + capture_output=True, timeout=30, creationflags=FLAGS) + require(result.returncode == 0, result.stderr.decode("utf8", errors="replace")) + return result.stdout + + def files(self): + return {name: sha(self.work / name) for name in ("changed.txt", "user.txt", "untracked.txt", ".git/index")} + + def file_revert(self): + self.git_command("init", "-q") + self.git_command("config", "core.autocrlf", "false") + (self.work / "changed.txt").write_bytes(b"old line\n") + (self.work / "user.txt").write_bytes(b"tracked original\n") + self.git_command("add", "--", "changed.txt", "user.txt") + self.git_command("-c", "user.name=Native E2E", "-c", "user.email=fixture@invalid", + "-c", "commit.gpgsign=false", "commit", "-qm", "isolated fixture") + (self.work / "user.txt").write_bytes(b"unrelated staged user content\n") + self.git_command("add", "--", "user.txt") + (self.work / "user.txt").write_bytes(b"unrelated unstaged user content\n") + (self.work / "untracked.txt").write_bytes(b"unrelated untracked user content\n") + initial = self.files() + c = self.rpc + capability = c.init.get("_meta", {}).get("fileRevert", {}) + require(capability.get("method") == "_session/files/revert" and capability.get("previewTokenRequired"), capability) + sid = c.new() + start = len(self.model.requests) + c.prompt(sid, "APPLY_SYNTHETIC_PATCH", patch=True) + require(len(self.model.requests) == start + 2, "Patch must issue tool call then consume native result") + require((self.work / "changed.txt").read_bytes() == b"native edited line\n", "Native patch did not edit bytes") + outputs = [item for item in self.model.requests[-1]["body"].get("input", []) + if item.get("type") == "custom_tool_call_output" and item.get("call_id") == CALL_ID] + require(outputs, "Native apply_patch output did not reach model") + save(self.run / "native-tool-output.json", outputs) + live = [entry["message"]["params"]["update"] for entry in c.trace + if entry["direction"] == "in" and entry["message"].get("method") == "session/update" + and entry["message"]["params"]["sessionId"] == sid] + + def verify_patch_events(updates): + edits = [value for value in updates if value.get("toolCallId") == CALL_ID] + require(any(value.get("kind") == "edit" for value in edits), edits) + require(any(value.get("status") == "completed" for value in edits), edits) + require("changed.txt" in json.dumps(edits), edits) + return edits + + live = verify_patch_events(live) + c = self.restart() + replay = verify_patch_events(c.load(sid)) + save(self.run / "file-change-events.json", {"live": live, "coldReplay": replay}) + self.step("real_apply_patch_bytes_native_output_and_cold_file_change_replay", sessionId=sid, + toolRoute=self.model.tool_route, hashes=self.files()) + request = {"sessionId": sid, "toolCallId": CALL_ID, "dryRun": True} + count = len(self.model.requests) + before = self.files() + preview = c.call("_session/files/revert", request) + require(preview.get("canRevert") and not preview.get("reverted") and preview.get("previewToken"), preview) + require(self.files() == before, "Preview mutated files/index") + missing = c.raw("_session/files/revert", {**request, "dryRun": False}) + require("error" in missing and self.files() == before, missing) + wrong = c.call("_session/files/revert", {**request, "dryRun": False, "previewToken": "sha256:" + "0" * 64}) + require(wrong.get("reason") == "stale_preview" and not wrong.get("reverted") and self.files() == before, wrong) + unknown = c.call("_session/files/revert", {**request, "toolCallId": "unknown-tool"}) + require(unknown.get("reason") == "completed_patch_not_found" and self.files() == before, unknown) + self.step("preview_is_read_only_and_missing_wrong_tokens_and_unknown_tool_refused", preview=preview) + # A deliberate same-line conflict is deterministic on both Git platforms. + # Restore only these fixture-owned bytes after verifying refusal. + (self.work / "changed.txt").write_bytes(b"synthetic conflicting external edit\n") + conflict_before = self.files() + stale = c.call("_session/files/revert", {**request, "dryRun": False, "previewToken": preview["previewToken"]}) + require(stale.get("reason") == "stale_preview" and not stale.get("reverted"), stale) + conflict = c.call("_session/files/revert", request) + require(conflict.get("reason") == "patch_conflict" and not conflict.get("canRevert") and not conflict.get("reverted"), conflict) + require(self.files() == conflict_before, "Conflict/stale token mutated files/index") + self.step("external_edit_invalidates_token_and_conflict_is_non_mutating", stale=stale, conflict=conflict) + (self.work / "changed.txt").write_bytes(b"native edited line\n") + before = self.files() + fresh = c.call("_session/files/revert", request) + require(fresh.get("canRevert") and fresh.get("previewToken") and self.files() == before, fresh) + restored = c.call("_session/files/revert", {**request, "dryRun": False, "previewToken": fresh["previewToken"]}) + require(restored.get("canRevert") and restored.get("reverted"), restored) + require(self.files() == initial, "Restore must recover changed.txt while preserving unrelated files/index bytes") + require(len(self.model.requests) == count, "File preview/revert called the model") + require([u["text"] for u in points(c.load(sid))] == ["APPLY_SYNTHETIC_PATCH"], "File revert changed conversation") + self.step("fresh_token_reverts_only_native_patch_preserving_index_and_independent_files", hashes=self.files()) + c.prompt(sid, "AFTER_FILE_RESTORE") + self.context(["APPLY_SYNTHETIC_PATCH", "AFTER_FILE_RESTORE"]) + require(self.files() == initial, "Continuing changed independent files/index") + self.step("file_restore_same_session_continues_with_conversation_intact") + def run_all(self): report = {"success": False, "build": self.meta, "steps": self.steps} try: self.verify_build() self.history() + if self.meta["mode"] != "rewind-only": + self.file_revert() self.rpc.stop() self.verify_build() self.model.check() @@ -493,7 +614,7 @@ def run_all(self): if os.name == "nt": require(all(entry.get("windowsHide") is True for entry in spawns), "Runtime spawn missing windowsHide: inspect spawns.jsonl") require(not any(entry.get("shell") for entry in native), "Direct native executable must not use a shell") - self.step("native_identity_windowsHide_and_clean_process_shutdown", clients=len(self.clients), spawnCount=len(spawns)) + self.step("runtime_native_identity_windowsHide_and_clean_process_shutdown", clients=len(self.clients), spawnCount=len(spawns)) report["success"] = True except Exception as error: report["error"] = {"type": type(error).__name__, "message": str(error)} @@ -531,5 +652,5 @@ def run_all(self): if __name__ == "__main__": - require(len(sys.argv) == 2, "Use npm run test:native:rewind (a fresh build manifest is required)") + require(len(sys.argv) == 2, "Use npm run test:native (a fresh build manifest is required)") raise SystemExit(Probe(Path(sys.argv[1])).run_all()) diff --git a/src/AcpExtensions.ts b/src/AcpExtensions.ts index cca4297fd..95363b81d 100644 --- a/src/AcpExtensions.ts +++ b/src/AcpExtensions.ts @@ -128,10 +128,11 @@ export async function legacySetSessionModel( export type SessionSteerRequest = { sessionId: SessionId; prompt: ContentBlock[]; + _meta?: {steering?: {idleBehavior?: "promptRequired"}}; } export type SessionSteeringResponse = { - outcome: "injected" | "startedNewTurn" | "failed"; + outcome: "injected" | "startedNewTurn" | "promptRequired" | "failed"; } export type SessionSteeringExtRequest = { diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index 18fb04e42..e84fca922 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -1,3 +1,4 @@ +import {probeSessionQueueSupport, type SessionQueueSupport} from "./SessionQueue"; import { type ApiKeyAuthRequest, CODEX_API_KEY_ENV_VAR, @@ -166,6 +167,8 @@ export class CodexAcpClient { return this.codexClient; } + queueSupport: SessionQueueSupport = {nativeVersion: null, actions: []}; + private readonly defaultClientInfo: ClientInfo = { name: `${packageJson.name}`, title: "Codex ACP", version: `${packageJson.version}` }; @@ -183,6 +186,10 @@ export class CodexAcpClient { } }); this.configPath = response?.codexHome ?? null; + // Native userAgent uses the requesting client name when Desktop originator env is absent. + // Its /version segment is the running native version, not clientInfo.version. + const version = response?.userAgent?.match(/^[^/]+\/(\d+\.\d+\.\d+)\s+\(/)?.[1]; + this.queueSupport = await probeSessionQueueSupport(version, action => action === "list" ? this.codexClient.probeQueueAction(action) : Promise.resolve(false)); } getHomePath(): string | null { @@ -791,6 +798,20 @@ export class CodexAcpClient { } } + async archiveSession(sessionId: string): Promise { + await this.codexClient.threadArchive({threadId: sessionId}); + } + async unarchiveSession(sessionId: string): Promise { + const response = await this.codexClient.threadUnarchive({threadId: sessionId}); + if (response.thread.id !== sessionId) throw new Error("Unarchive returned a different thread"); + } + async installedPlugins(cwds: string[]) { + return this.codexClient.pluginInstalled({cwds}); + } + async reconcilePlugins() { + return this.codexClient.pluginReconcile({reason: "ACP runtime refresh"}); + } + async deleteSession(sessionId: string): Promise { try { await this.codexClient.threadArchive({threadId: sessionId}); diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index 9d8344506..5694005fb 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -1,8 +1,14 @@ +import {terminateCodexConnection} from "./CodexJsonRpcConnection"; +import {fileRevertCapability, revertSessionFiles, type SessionFileRevertRequest} from "./SessionFileRevert"; +import {parseSessionQueueRequest, runSessionQueue, sessionQueueCapability, type SessionQueueRequest} from "./SessionQueue"; +import {sessionDiscoveryCapability, searchSessions, sessionAttachments, type SessionSearchRequest, type SessionAttachmentsRequest} from "./SessionDiscovery"; +import {archiveCapability} from "./SessionArchive"; import * as acp from "@agentclientprotocol/sdk"; import {RequestError, type SessionId, type SessionModeState} from "@agentclientprotocol/sdk"; import {CodexEventHandler, type CompletedPlan} from "./CodexEventHandler"; import {attachmentFileUri} from "./DesktopAttachmentHistory"; import {userInputToContentBlocks} from "./UserInputContent"; +import {runtimeCapability, runtimeReadParser, runtimeControlParser, readRuntime, controlRuntime, type RuntimeReadRequest, type RuntimeControlRequest, type RuntimeResponse} from "./SessionRuntime"; import {CodexApprovalHandler} from "./permissions/CodexApprovalHandler"; import {PermissionLifecycleContext} from "./permissions/lifecycle"; import {CodexElicitationHandler} from "./CodexElicitationHandler"; @@ -357,6 +363,11 @@ export class CodexAcpServer { private codexProcessGeneration = 0; private initializeRequest: acp.InitializeRequest | null = null; private providerUpdate: Promise | null = null; + private providerRuntimeControl = false; + private providerCreationReservations = 0; + private providerInvalidated = false; + private providerReads = 0; + private readonly runtimeControls = new Set(); private readonly rewinds = new Map>(); private readonly rewindRecoveryRequired = new Set(); private readonly sessionOperations = new Map(); @@ -476,8 +487,14 @@ export class CodexAcpServer { }, authMethods: getCodexAuthMethods(_params.clientCapabilities), _meta: { + runtime: runtimeCapability(), + archive: archiveCapability(), + fileRevert: fileRevertCapability(), + discovery: sessionDiscoveryCapability(), + ...(sessionQueueCapability(this.codexAcpClient.queueSupport) ? {queue: sessionQueueCapability(this.codexAcpClient.queueSupport)} : {}), steering: { supported: true, + idleBehavior: ["promptRequired"], }, // Only AIR gets the AIR extension, see `docs/air-extensions.md`. ...(this.capabilities.airClient ? { @@ -504,7 +521,41 @@ export class CodexAcpServer { }; } + private assertProviderAvailable(): void { + if (this.providerInvalidated) throw RequestError.invalidRequest("Native provider was invalidated after a timeout; reconnect the ACP adapter"); + } + + private async invalidateProviderAfterTimeout(): Promise { + // Poison admission BEFORE disposing the transport: late native completions must never + // install or publish a session, even if process termination itself fails. + this.providerInvalidated = true; + for (const session of this.sessions.values()) { + session.asyncTasks.clear(); + this.bumpSessionGeneration(session.sessionId); + this.mcpSessionStartup.close(session.sessionId); + } + for (const prompt of this.activePrompts.values()) prompt.requestClose(); + for (const pending of this.pendingTurnStarts.values()) pending.resolve(null); + this.sessions.clear(); + this.pendingTurnStarts.clear(); + this.steeringQueues.clear(); + if (this.codexProcessState !== null) await terminateCodexConnection(this.codexProcessState.connection); + } + + private async boundedRuntimeControl(operation: () => Promise): Promise { + const pending = operation(); + // settledWithin observes the losing promise as well; it never becomes an unhandled rejection. + const result = await settledWithin(pending.then(value => ({value})), 30_000); + if (result === "pending") { + await this.invalidateProviderAfterTimeout(); + throw RequestError.internalError(undefined, "Native runtime operation timed out; provider terminated, reconnect the ACP adapter"); + } + return result.value; + } + private assertNotRewinding(sessionId: string): void { + this.assertProviderAvailable(); + if (this.providerRuntimeControl || this.runtimeControls.has(sessionId)) throw RequestError.invalidRequest("Runtime control is in progress"); if (this.rewinds.has(sessionId)) throw RequestError.invalidRequest(`Session ${sessionId} is rewinding`); } @@ -569,6 +620,155 @@ export class CodexAcpServer { } } + async revertFiles(request: SessionFileRevertRequest) { + while (this.providerUpdate !== null) await this.providerUpdate; + this.assertNotRewinding(request.sessionId); + const state = this.getSessionState(request.sessionId); + if (this.providerCreationReservations || this.rewinds.size || this.sessionOperations.size || this.providerReads || this.activePrompts.size + || this.pendingTurnStarts.size || this.closingSessions.size) throw RequestError.invalidRequest("Provider is busy; settle work before reverting files"); + this.providerRuntimeControl = true; + try { + // Include native-loaded child threads, not just root ACP sessions. Fail closed if + // this native version cannot prove that its background terminals are idle. + await this.boundedRuntimeControl(async () => { + let cursor: string | null = null; + const seen = new Set(); + do { + const page = await this.codexAcpClient.appServerClient.threadLoadedList({cursor, limit: 100}); + for (const threadId of page.data) { + const thread = await this.codexAcpClient.appServerClient.threadRead({threadId}); + if (thread.thread.status.type === "active") throw RequestError.invalidRequest("Native thread is active; settle work before reverting files"); + const terminals = await this.codexAcpClient.appServerClient.threadBackgroundTerminalsList({threadId, limit: 1}); + if (terminals.data.length || terminals.nextCursor) throw RequestError.invalidRequest("Native background work is active"); + } + cursor = page.nextCursor; + if (cursor && seen.has(cursor)) throw RequestError.internalError(undefined, "Repeated loaded-thread cursor"); + if (cursor) seen.add(cursor); + } while (cursor); + }); + const result = await revertSessionFiles(request, state, () => this.boundedRuntimeControl(() => this.codexAcpClient.appServerClient.threadReadWithHistory(state.sessionId))); + if (result.reason === "restore_outcome_unknown") this.rewindRecoveryRequired.add(state.sessionId); + return result; + } finally { this.providerRuntimeControl = false; } + } + + private async assertNoPendingNativeQueue(sessionId: string): Promise { + if (!this.codexAcpClient.queueSupport.actions.includes("list")) return; + if (this.sessionIsClosing(sessionId)) throw RequestError.invalidRequest(`Session ${sessionId} is closing`); + const generation = this.getSessionGeneration(sessionId); + const client = this.codexAcpClient; + let page; + try { page = await client.appServerClient.queueNative().list({threadId: sessionId, limit: 1}); } + catch (error) { + // Preserve the existing public invalid-session diagnostic; this is a read validation + // error, never a reason to resume, mutate history or skip pending-queue checks. + if (error instanceof Error && error.message.startsWith("invalid thread id:")) { + throw RequestError.invalidParams(undefined, "invalid session id"); + } + throw error; + } + if (this.codexAcpClient !== client || this.getSessionGeneration(sessionId) !== generation || this.sessionIsClosing(sessionId)) { + throw RequestError.invalidRequest(`Session ${sessionId} changed while checking its native queue`); + } + if (page.data.length || page.nextCursor) throw RequestError.invalidRequest("Native pending queue requires an owning native client; ACP auto-dispatch is not supported"); + } + + async manageSessionQueue(value: unknown, signal?: AbortSignal): Promise { + const request = parseSessionQueueRequest(value); + while (this.providerUpdate !== null) await this.providerUpdate; + this.assertNotRewinding(request.sessionId); + this.getSessionState(request.sessionId); + const capability = sessionQueueCapability(this.codexAcpClient.queueSupport); + if (!capability?.actions.includes(request.action)) return {status: "unsupported"}; + if (this.sessionIsClosing(request.sessionId) || this.sessionOperations.has(request.sessionId)) throw RequestError.invalidRequest("Session is busy"); + return this.sessionOperation(request.sessionId, async () => { + this.runtimeControls.add(request.sessionId); + try { return await runSessionQueue(request, {sessionId: request.sessionId, support: this.codexAcpClient.queueSupport, native: this.codexAcpClient.appServerClient.queueNative()}); } + finally { this.runtimeControls.delete(request.sessionId); } + }); + } + + async searchSessionHistory(params: SessionSearchRequest) { + while (this.providerUpdate !== null) await this.providerUpdate; + if (this.providerRuntimeControl) throw RequestError.invalidRequest("Runtime control is in progress"); + this.providerReads++; + try { return await searchSessions(this.codexAcpClient.appServerClient, params); } + finally { this.providerReads--; } + } + + async manageSessionAttachments(params: SessionAttachmentsRequest) { + while (this.providerUpdate !== null) await this.providerUpdate; + return this.sessionOperation(params.sessionId, async () => { + const state = this.getSessionState(params.sessionId); + if (this.sessionIsClosing(params.sessionId)) throw RequestError.invalidRequest("Session is closing"); + this.runtimeControls.add(params.sessionId); + try { return await sessionAttachments(this.codexAcpClient.appServerClient, state.sessionId, params); } + finally { this.runtimeControls.delete(params.sessionId); } + }); + } + + async readSessionRuntime(request: RuntimeReadRequest, signal: AbortSignal = new AbortController().signal): Promise { + request = runtimeReadParser.parse(request); + while (this.providerUpdate !== null) await this.providerUpdate; + return this.sessionOperation(request.sessionId, async () => { + const state = this.getSessionState(request.sessionId); + const client = this.codexAcpClient; + const generation = this.getSessionGeneration(request.sessionId); + return readRuntime(client, state, request, signal, () => + this.codexAcpClient === client && this.sessionPublishIsCurrent(state, generation)); + }); + } + + async controlSessionRuntime(request: RuntimeControlRequest): Promise { + request = runtimeControlParser.parse(request); + while (this.providerUpdate !== null) await this.providerUpdate; + this.assertNotRewinding(request.sessionId); + const state = this.getSessionState(request.sessionId); + if (request.action === "reconnectMcp" || request.action === "reloadPlugins") { + if (this.providerCreationReservations || this.rewinds.size || this.sessionOperations.size || this.providerReads || this.activePrompts.size + || this.pendingTurnStarts.size || this.closingSessions.size) { + throw RequestError.invalidRequest("Provider is busy; settle its sessions before reconnecting MCP"); + } + this.providerRuntimeControl = true; + try { return await this.boundedRuntimeControl(() => this.runWithProcessCheck(() => controlRuntime(this.codexAcpClient, state, request))); } + finally { this.providerRuntimeControl = false; } + } + if (this.sessionOperations.has(request.sessionId) || this.sessionIsClosing(request.sessionId)) { + throw RequestError.invalidRequest("Session lifecycle or settings update is in progress"); + } + return this.sessionOperation(request.sessionId, async () => { + this.runtimeControls.add(request.sessionId); + try { + return await this.boundedRuntimeControl(async () => { + const result = await this.runWithProcessCheck(() => controlRuntime(this.codexAcpClient, state, request)); + this.assertProviderAvailable(); + await this.publishAvailableCommands(state, this.getSessionGeneration(request.sessionId)); + return result; + }); + } finally { this.runtimeControls.delete(request.sessionId); } + }); + } + + async archiveSession(sessionId: string, archived: boolean): Promise<{sessionId: string; archived: boolean}> { + while (this.providerUpdate !== null) await this.providerUpdate; + this.assertNotRewinding(sessionId); + if (this.sessionOperations.has(sessionId) || this.activePrompts.has(sessionId) || this.pendingTurnStarts.has(sessionId) + || this.sessionIsClosing(sessionId)) throw RequestError.invalidRequest("Session is busy; settle work before archiving"); + // Hold a named fence until native archive/unarchive completes. closeSession is cleanup, + // not a writer-lock handoff: the same app-server performs the archive. + return this.sessionOperation(sessionId, async () => { + this.beginSessionCloseFence(sessionId); + try { + if (this.hasLocalSession(sessionId) && this.sessions.has(sessionId)) await this.closeSessionDuringOperation({sessionId}); + await this.runWithProcessCheck(() => archived + ? this.codexAcpClient.archiveSession(sessionId) + : this.codexAcpClient.unarchiveSession(sessionId)); + this.rewindRecoveryRequired.delete(sessionId); + return {sessionId, archived}; + } finally { this.endSessionCloseFence(sessionId); } + }); + } + async listHooks(cwd: string): Promise { if (this.providerUpdate !== null) await this.providerUpdate; return await this.runWithProcessCheck(() => listCodexHooks(this.codexAcpClient.appServerClient, cwd)); @@ -1101,6 +1301,7 @@ export class CodexAcpServer { } private installSessionState(sessionState: SessionState): void { + this.assertProviderAvailable(); this.sessions.get(sessionState.sessionId)?.asyncTasks.clear(); this.sessions.set(sessionState.sessionId, sessionState); } @@ -1121,6 +1322,7 @@ export class CodexAcpServer { } private async loadSessionDuringOperation(params: acp.LoadSessionRequest): Promise { + if (this.codexAcpClient.queueSupport.actions.includes("list")) await this.assertNoPendingNativeQueue(params.sessionId); if (this.providerUpdate !== null) { await this.providerUpdate; } @@ -1176,6 +1378,7 @@ export class CodexAcpServer { } private async resumeSessionDuringOperation(params: acp.ResumeSessionRequest): Promise { + if (this.codexAcpClient.queueSupport.actions.includes("list")) await this.assertNoPendingNativeQueue(params.sessionId); if (this.providerUpdate !== null) { await this.providerUpdate; } @@ -1241,6 +1444,7 @@ export class CodexAcpServer { } async closeSession(params: acp.CloseSessionRequest): Promise { + if (this.providerInvalidated) return {}; return this.sessionOperation(params.sessionId, async () => { const result = await this.closeSessionDuringOperation(params); return result; @@ -1330,9 +1534,13 @@ export class CodexAcpServer { async newSession( params: acp.NewSessionRequest, ): Promise { - if (this.providerUpdate !== null) { - await this.providerUpdate; - } + this.assertProviderAvailable(); + if (this.providerRuntimeControl) throw RequestError.invalidRequest("Runtime control is in progress"); + this.providerCreationReservations++; + try { + while (this.providerUpdate !== null) await this.providerUpdate; + this.assertProviderAvailable(); + if (this.providerRuntimeControl) throw RequestError.invalidRequest("Runtime control is in progress"); logger.log("Starting new session..."); const [sessionId, modelState, modeState] = await this.getOrCreateSession(params); @@ -1348,13 +1556,15 @@ export class CodexAcpServer { modes: modeState, ...this.createSessionConfigOptionsResponse(this.getSessionState(sessionId)), }; + } finally { this.providerCreationReservations--; } } async authenticate( _params: acp.AuthenticateRequest, requestId?: acp.JsonRpcId, ): Promise { - if (this.rewinds.size > 0) throw RequestError.invalidRequest("Session rewind is in progress"); + this.assertProviderAvailable(); + if (this.providerCreationReservations || this.providerRuntimeControl || this.rewinds.size > 0) throw RequestError.invalidRequest("Provider history or runtime operation is in progress"); logger.log("Authenticate request received"); const elicitationRequester = this.createUrlElicitationRequester(requestId); const isAuthenticated = await this.runWithProcessCheck(() => this.codexAcpClient.authenticate(_params, elicitationRequester)); @@ -1393,7 +1603,8 @@ export class CodexAcpServer { } async logout(_params: acp.LogoutRequest): Promise { - if (this.rewinds.size > 0) throw RequestError.invalidRequest("Session rewind is in progress"); + this.assertProviderAvailable(); + if (this.providerCreationReservations || this.providerRuntimeControl || this.rewinds.size > 0) throw RequestError.invalidRequest("Provider history or runtime operation is in progress"); logger.log("Logout request received"); await this.runWithProcessCheck(() => this.codexAcpClient.logout()); await this.refreshAuthState(null); @@ -1405,16 +1616,22 @@ export class CodexAcpServer { } async setProvider(params: acp.SetProviderRequest): Promise { + this.assertProviderAvailable(); + if (this.providerRuntimeControl || this.providerCreationReservations) throw RequestError.invalidRequest("Runtime control or session creation is in progress"); while (this.rewinds.size > 0) await Promise.all([...this.rewinds.values()]); - if (this.sessionOperations.size > 0) throw RequestError.invalidRequest("A session lifecycle or settings operation is in progress"); + if (this.providerRuntimeControl) throw RequestError.invalidRequest("Runtime control is in progress"); + if (this.providerCreationReservations || this.sessionOperations.size > 0 || this.providerReads > 0) throw RequestError.invalidRequest("A session lifecycle or settings operation is in progress"); this.codexAcpClient.setProvider(params); await this.enqueueProviderUpdate((client) => client.setProvider(params)); return { }; } async disableProvider(params: acp.DisableProviderRequest): Promise { + this.assertProviderAvailable(); + if (this.providerRuntimeControl || this.providerCreationReservations) throw RequestError.invalidRequest("Runtime control or session creation is in progress"); while (this.rewinds.size > 0) await Promise.all([...this.rewinds.values()]); - if (this.sessionOperations.size > 0) throw RequestError.invalidRequest("A session lifecycle or settings operation is in progress"); + if (this.providerRuntimeControl) throw RequestError.invalidRequest("Runtime control is in progress"); + if (this.providerCreationReservations || this.sessionOperations.size > 0 || this.providerReads > 0) throw RequestError.invalidRequest("A session lifecycle or settings operation is in progress"); this.codexAcpClient.disableProvider(params); if (params.providerId !== OPENAI_PROVIDER_ID) { return { }; @@ -1454,6 +1671,10 @@ export class CodexAcpServer { for (const session of this.sessions.values()) { session.asyncTasks.setAppServer(replacement.appServerClient); try { + if (replacement.queueSupport.actions.includes("list")) { + const pending = await replacement.appServerClient.queueNative().list({threadId: session.sessionId, limit: 1}); + if (pending.data.length || pending.nextCursor) throw RequestError.invalidRequest("Native pending queue cannot be auto-resumed by ACP"); + } await replacement.resumeSession({ sessionId: session.sessionId, cwd: session.cwd, @@ -1979,6 +2200,7 @@ export class CodexAcpServer { return {outcome: "injected"}; } } + if (params._meta?.steering?.idleBehavior === "promptRequired") return {outcome: "promptRequired"}; return await this.startNewTurnFromSteering(params); } @@ -2159,9 +2381,14 @@ export class CodexAcpServer { if (typeof sessionId !== "string" || !Array.isArray(prompt)) { throw RequestError.invalidParams(); } + const meta = params["_meta"]; + const steering = typeof meta === "object" && meta !== null && "steering" in meta ? meta.steering : undefined; + const idleBehavior = typeof steering === "object" && steering !== null && "idleBehavior" in steering ? steering.idleBehavior : undefined; + if (idleBehavior !== undefined && idleBehavior !== "promptRequired") throw RequestError.invalidParams(undefined, "Unknown steering idle behavior"); return { - sessionId: sessionId, + sessionId, prompt: prompt as acp.ContentBlock[], + ...(idleBehavior === "promptRequired" ? {_meta: {steering: {idleBehavior}}} : {}), }; } @@ -2262,7 +2489,7 @@ export class CodexAcpServer { } private sessionPublishIsCurrent(sessionState: SessionState, sessionGeneration: number): boolean { - return this.sessions.get(sessionState.sessionId) === sessionState + return !this.providerInvalidated && this.sessions.get(sessionState.sessionId) === sessionState && this.getSessionGeneration(sessionState.sessionId) === sessionGeneration && !this.sessionIsClosing(sessionState.sessionId); } @@ -3664,6 +3891,7 @@ export class CodexAcpServer { } private async runWithProcessCheck(operation: () => Promise): Promise { + this.assertProviderAvailable(); try { return await operation(); } catch (err) { diff --git a/src/CodexAppServerClient.ts b/src/CodexAppServerClient.ts index 92bcec634..bd56eadec 100644 --- a/src/CodexAppServerClient.ts +++ b/src/CodexAppServerClient.ts @@ -1,3 +1,7 @@ +import type {SessionQueueAction, SessionQueueNative} from "./SessionQueue"; +import type {NativeThreadSearchParams, NativeThreadSearchResponse} from "./SessionDiscovery"; +import type {ThreadAttachmentListParams, ThreadAttachmentListResponse, ThreadAttachmentAddParams, ThreadAttachmentAddResponse, ThreadAttachmentRemoveParams, ThreadAttachmentRemoveResponse} from "./app-server/v2"; +import type {ThreadUnarchiveParams, ThreadUnarchiveResponse, PluginInstalledParams, PluginInstalledResponse, PluginReconcileParams, PluginReconcileResponse} from "./app-server/v2"; import {type MessageConnection, RequestType} from "vscode-jsonrpc/node"; import {McpOauthCompletions} from "./mcp/McpOauthCompletions"; import {McpStartupTracker} from "./mcp/McpStartupTracker"; @@ -731,6 +735,49 @@ export class CodexAppServerClient { } } + /** Invalid parameters only: proves method registration without a valid mutation target. */ + async probeQueueAction(action: SessionQueueAction): Promise { + try { await this.connection.sendRequest(`thread/queue/${action}`, {}); return false; } + catch (error) { + return typeof error === "object" && error !== null && "code" in error && error.code === -32600 + && "message" in error && error.message === "Invalid request: missing field `threadId`"; + } + } + + queueNative(): SessionQueueNative { + return { + list: params => this.connection.sendRequest("thread/queue/list", params), + add: params => this.connection.sendRequest("thread/queue/add", params), + update: params => this.connection.sendRequest("thread/queue/update", params), + delete: params => this.connection.sendRequest("thread/queue/delete", params), + reorder: params => this.connection.sendRequest("thread/queue/reorder", params), + start: params => this.connection.sendRequest("thread/queue/start", params), + }; + } + + async threadSearch(params: NativeThreadSearchParams): Promise { + return this.connection.sendRequest("thread/search", {...params, sourceKinds: ["appServer", "cli", "vscode"]}); + } + async threadAttachmentList(params: ThreadAttachmentListParams): Promise { + return this.sendRequest({method: "thread/attachment/list", params}); + } + async threadAttachmentAdd(params: ThreadAttachmentAddParams): Promise { + return this.sendRequest({method: "thread/attachment/add", params}); + } + async threadAttachmentRemove(params: ThreadAttachmentRemoveParams): Promise { + return this.sendRequest({method: "thread/attachment/remove", params}); + } + + async threadUnarchive(params: ThreadUnarchiveParams): Promise { + return this.sendRequest({method: "thread/unarchive", params}); + } + async pluginInstalled(params: PluginInstalledParams): Promise { + return this.sendRequest({method: "plugin/installed", params}); + } + async pluginReconcile(params: PluginReconcileParams): Promise { + return this.sendRequest({method: "plugin/reconcile", params}); + } + async threadArchive(params: ThreadArchiveParams): Promise { return await this.sendRequest({ method: "thread/archive", params: params }); } diff --git a/src/CodexJsonRpcConnection.ts b/src/CodexJsonRpcConnection.ts index 701b36627..eac4b73cd 100644 --- a/src/CodexJsonRpcConnection.ts +++ b/src/CodexJsonRpcConnection.ts @@ -3,12 +3,22 @@ import type {MessageConnection} from "vscode-jsonrpc/node"; import {spawn, type ChildProcessWithoutNullStreams} from "node:child_process"; import {createRequire} from "node:module"; import crossSpawn from "cross-spawn"; +import type {Socket} from "node:net"; +import {ownedProcessHelperArgs} from "./CodexProcessHelpers"; import {createJSONRPCReader, createJSONRPCWriter} from "./StdUtils"; import {logger} from "./Logger"; const require = createRequire(import.meta.url); +const terminationTimeoutMs = 5_000; +interface OwnedProcess { + groupId?: number; + control?: Socket; + termination?: Promise; +} +const ownedProcesses = new WeakMap(); + export interface CodexConnection { readonly connection: MessageConnection readonly process: ChildProcessWithoutNullStreams; @@ -22,15 +32,29 @@ export function startCodexConnection( const spawnEnv = env ?? process.env; let codex: ChildProcessWithoutNullStreams; - if (codexPath) { + if (process.platform !== "win32") { + const command = codexPath ?? process.execPath; + const args = codexPath ? appServerStartupArgs + : [require.resolve("@openai/codex/bin/codex.js"), ...appServerStartupArgs]; + codex = spawn(process.execPath, ownedProcessHelperArgs("supervisor", [command, ...args]), { + env: spawnEnv, + windowsHide: true, + detached: true, + stdio: ["pipe", "pipe", "pipe", "pipe"], + }) as ChildProcessWithoutNullStreams; + const control = codex.stdio[3] as Socket; + // A broken control pipe is checked by termination via group disappearance. + control.on("error", () => {}); + control.unref(); // This extra lifeline must not keep the adapter event loop alive. + ownedProcesses.set(codex, {...(codex.pid === undefined ? {} : {groupId: codex.pid}), control}); + } else if (codexPath) { // cross-spawn runs `.cmd` shims through cmd.exe with correct escaping for the TOML hook override. - codex = process.platform === "win32" - ? crossSpawn(codexPath, appServerStartupArgs, {env: spawnEnv, windowsHide: true}) as ChildProcessWithoutNullStreams - : spawn(codexPath, appServerStartupArgs, {env: spawnEnv, windowsHide: true}); + codex = crossSpawn(codexPath, appServerStartupArgs, {env: spawnEnv, windowsHide: true}) as ChildProcessWithoutNullStreams; } else { const bundledCodexPath = require.resolve("@openai/codex/bin/codex.js"); codex = spawn(process.execPath, [bundledCodexPath, ...appServerStartupArgs], {env: spawnEnv, windowsHide: true}); } + if (!ownedProcesses.has(codex)) ownedProcesses.set(codex, {}); attachLogs(codex); @@ -66,3 +90,68 @@ function attachLogs(proc: ChildProcessWithoutNullStreams) { logger.log(`[EXIT] code: ${code?.toString()}`); }); } + +/** Stops an unresponsive owned provider before allowing the client to reconnect. */ +export function terminateCodexConnection(native: CodexConnection): Promise { + const child = native.process; + let owned = ownedProcesses.get(child); + if (!owned) { + // Do not infer group ownership from a caller-supplied PID. + owned = {}; + ownedProcesses.set(child, owned); + owned.termination = Promise.reject(new Error("Cannot terminate an unowned provider tree")); + } + // Cache failures as well as successes: retrying taskkill against an old PID is unsafe. + return owned.termination ??= (async () => { + native.connection.dispose(); + if (child.pid === undefined) return; // spawn failed; no OS process was created + if (owned.groupId !== undefined) { + owned.control!.end("stop"); + await waitForTermination(() => hasExited(child) && !groupExists(owned.groupId!)); + } else { + // A reaped Windows launcher no longer proves ownership of its PID/tree. + if (hasExited(child)) throw new Error("Provider exited before tree termination; tree state is unknown"); + await terminateWindowsTree(child.pid); + await waitForTermination(() => hasExited(child)); + } + })(); +} + +function hasExited(child: ChildProcessWithoutNullStreams): boolean { + return child.exitCode !== null || child.signalCode !== null; +} + +function groupExists(groupId: number): boolean { + try { + process.kill(-groupId, 0); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ESRCH") return false; + throw error; + } +} + +async function waitForTermination(stopped: () => boolean): Promise { + const deadline = Date.now() + terminationTimeoutMs; + while (!stopped()) { + if (Date.now() >= deadline) throw new Error("Provider tree termination timed out; tree state is unknown"); + await new Promise(resolve => setTimeout(resolve, 20)); + } +} + +function terminateWindowsTree(pid: number): Promise { + const killer = spawn("taskkill", ["/PID", String(pid), "/T", "/F"], {windowsHide: true, stdio: "ignore"}); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + killer.kill(); + reject(new Error("Provider tree termination timed out; tree state is unknown")); + }, terminationTimeoutMs); + killer.once("error", error => {clearTimeout(timer); reject(error);}); + killer.once("exit", code => { + clearTimeout(timer); + // Launcher exit alone cannot turn a failed tree kill into success. + if (code === 0) resolve(); + else reject(new Error("Provider tree termination failed; tree state is unknown")); + }); + }); +} diff --git a/src/CodexProcessHelpers.ts b/src/CodexProcessHelpers.ts new file mode 100644 index 000000000..2f9673a77 --- /dev/null +++ b/src/CodexProcessHelpers.ts @@ -0,0 +1,88 @@ +import {createRequire} from "node:module"; + +const prefix = "--internal-owned-process-"; +type HelperRole = "supervisor" | "guardian"; + +// Keep this function self-contained: Node/Bun script runtimes execute this fixed +// built-in function with -e; compiled Bun dispatches to the same function below. +// No source code is accepted from arguments or the environment. +function helperMain(require: NodeRequire, role: string, args: string[], compiled: boolean): void { + const {spawn} = require("node:child_process") as typeof import("node:child_process"); + const {fstatSync, closeSync, read} = require("node:fs") as typeof import("node:fs"); + const checks = {pipe(fd: number) { + const stat = fstatSync(fd); + if (!stat.isSocket() && !stat.isFIFO()) throw new Error("Missing private process-helper pipe"); + }}; + if (process.platform === "win32") throw new Error("POSIX process helper required"); + checks.pipe(3); + if (role === "guardian") { + checks.pipe(0); + const group = Number(args[0]); + if (args.length !== 1 || !Number.isSafeInteger(group) || group <= 1 || process.ppid !== group) { + throw new Error("Invalid process-helper parent"); + } + // Our live membership pins this group ID even after the leader exits. + for (const fd of [0, 3]) { + // One byte, EOF, or an error all mean stop. Raw reads also work for + // Bun's inherited pipes, which need not be supported by net.Socket. + read(fd, Buffer.alloc(1), 0, 1, null, () => process.kill(-group, "SIGKILL")); + } + process.stdout.write("ready"); + return; + } + if (role !== "supervisor" || !args[0]) throw new Error("Invalid process-helper invocation"); + // detached spawn must have established a group named after this live PID. + // Refuse to install a guardian when invoked in a caller's shared group. + process.kill(-process.pid, 0); + let provider: import("node:child_process").ChildProcess | undefined; + let pendingSignal: NodeJS.Signals | undefined; + for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) { + process.on(signal, () => { + if (provider) provider.kill(signal); + else pendingSignal = signal; + }); + } + const guardianArgs = compiled + ? ["--internal-owned-process-guardian", String(process.pid)] + : ["-e", `(${helperMain.toString()})(require, "guardian", process.argv.slice(1), false)`, String(process.pid)]; + const guardian = spawn(process.execPath, guardianArgs, {stdio: [3, "pipe", "inherit", "pipe"], windowsHide: true}); + closeSync(3); + guardian.on("error", error => {console.error(error); process.exit(1);}); + guardian.on("exit", () => process.exit(1)); + guardian.stdout!.once("data", () => { + provider = spawn(args[0]!, args.slice(1), {stdio: "inherit", windowsHide: true}); + provider.on("error", error => {console.error(error); process.exit(1);}); + provider.on("exit", (code, signal) => { + if (signal) { + process.removeAllListeners(signal); + process.kill(process.pid, signal); + } else process.exit(code ?? 1); + }); + if (pendingSignal) provider.kill(pendingSignal); + }); +} + +function isCompiledBun(): boolean { + // Bun.main is inside the embedded filesystem only for compiled executables. + const main = (globalThis as typeof globalThis & {Bun?: {main: string}}).Bun?.main; + return main?.startsWith("/$bunfs/") === true || main?.startsWith("B:/~BUN/") === true; +} + +export function ownedProcessHelperArgs(role: HelperRole, args: string[]): string[] { + return isCompiledBun() ? [`${prefix}${role}`, ...args] + : ["-e", `(${helperMain.toString()})(require, ${JSON.stringify(role)}, process.argv.slice(1), false)`, ...args]; +} + +/** Must run before importing the ACP application or reading its configuration. */ +export async function runInternalProcessHelper(): Promise { + const flag = process.argv[2]; + if (!flag?.startsWith(prefix)) return; + try { + helperMain(createRequire(import.meta.url), flag.slice(prefix.length), process.argv.slice(3), true); + // Never fall through into the ACP application while the helper is alive. + await new Promise(() => {}); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exit(1); + } +} diff --git a/src/DesktopAttachmentHistory.ts b/src/DesktopAttachmentHistory.ts index 932ccd473..dbd093b49 100644 --- a/src/DesktopAttachmentHistory.ts +++ b/src/DesktopAttachmentHistory.ts @@ -20,7 +20,7 @@ export function attachmentFileUri(path: string): string | null { return null; } } - return path.startsWith("/") ? pathToFileURL(path).href : null; + return path.startsWith("/") ? pathToFileURL(path, {windows: false}).href : null; } /** Converts the known Desktop attachment envelope into history content blocks. */ diff --git a/src/SessionArchive.ts b/src/SessionArchive.ts new file mode 100644 index 000000000..0884b377f --- /dev/null +++ b/src/SessionArchive.ts @@ -0,0 +1,7 @@ +import {z} from "zod"; +export const SESSION_ARCHIVE_METHOD = "_session/archive"; +export const SESSION_UNARCHIVE_METHOD = "_session/unarchive"; +export const sessionArchiveParser = z.object({sessionId: z.string().trim().min(1)}).strict(); +export function archiveCapability() { + return {version: 1, archiveMethod: SESSION_ARCHIVE_METHOD, unarchiveMethod: SESSION_UNARCHIVE_METHOD, permanentDelete: false}; +} diff --git a/src/SessionDiscovery.ts b/src/SessionDiscovery.ts new file mode 100644 index 000000000..c4c1f2b71 --- /dev/null +++ b/src/SessionDiscovery.ts @@ -0,0 +1,73 @@ +import {RequestError} from "@agentclientprotocol/sdk"; +import {z} from "zod"; +import type {Thread, ThreadAttachmentAddParams, ThreadAttachmentAddResponse, ThreadAttachmentListParams, ThreadAttachmentListResponse, ThreadAttachmentRemoveParams, ThreadAttachmentRemoveResponse} from "./app-server/v2"; + +export const SESSION_SEARCH_METHOD = "_session/search"; +export const SESSION_ATTACHMENTS_METHOD = "_session/attachments"; +const identifier = z.string().min(1).max(4096).refine(value => value.trim().length > 0 && !Array.from(value).some(char => char.charCodeAt(0) < 32)); +const page = {cursor: identifier.nullable().optional(), limit: z.number().int().min(1).max(100).optional()}; +export const sessionSearchParser = z.object({ + searchTerm: z.string().trim().min(1).max(4096), + archived: z.boolean().optional(), + ...page, +}).strict(); +export type SessionSearchRequest = z.infer; +// Typed experimental method omitted from the package's generated subset. Shape +// verified against native 0.160.1 and the public app-server protocol source. +export type NativeThreadSearchParams = SessionSearchRequest; +export type NativeThreadSearchResponse = {data: Array<{thread: Thread, snippet: string}>, nextCursor: string | null, backwardsCursor?: string | null}; + +const attachmentIdentity = {sessionId: identifier, attachmentType: identifier, identityKey: identifier}; +const boundedJson = z.json().refine(value => Buffer.byteLength(JSON.stringify(value), "utf8") <= 256 * 1024, "Attachment payload exceeds 256 KiB"); +export const sessionAttachmentsParser = z.discriminatedUnion("action", [ + z.object({sessionId: identifier, action: z.literal("list"), ...page}).strict(), + z.object({...attachmentIdentity, action: z.literal("add"), payload: boundedJson}).strict(), + z.object({...attachmentIdentity, action: z.literal("remove")}).strict(), +]); +export type SessionAttachmentsRequest = z.infer; +export type SessionDiscoveryClient = { + threadSearch(params: NativeThreadSearchParams): Promise; + threadAttachmentList(params: ThreadAttachmentListParams): Promise; + threadAttachmentAdd(params: ThreadAttachmentAddParams): Promise; + threadAttachmentRemove(params: ThreadAttachmentRemoveParams): Promise; +}; + +export function sessionDiscoveryCapability() { + return { + version: 1, + searchMethod: SESSION_SEARCH_METHOD, + attachmentMethod: SESSION_ATTACHMENTS_METHOD, + attachmentActions: ["list", "add", "remove"], + attachmentsAreMetadata: true, + }; +} + +/** Search is native text search, not a model prompt or a local scan of files. */ +export async function searchSessions(client: Pick, params: SessionSearchRequest) { + const response = await client.threadSearch(sessionSearchParser.parse(params)); + return { + version: 1, + sessions: response.data.map(({thread, snippet}) => ({ + sessionId: thread.id, cwd: thread.cwd, title: thread.name ?? thread.preview, + updatedAt: thread.updatedAt, snippet, + })), + nextCursor: response.nextCursor, + }; +} + +/** These are independently persisted native thread attachments (PR links, + * worktree associations, etc.), not prompt images or disk operations. The host + * remains responsible for actually creating/removing any associated resource. */ +export async function sessionAttachments( + client: Omit, + sessionId: string, + params: SessionAttachmentsRequest, +): Promise<{version: 1, data: unknown}> { + params = sessionAttachmentsParser.parse(params); + if (params.sessionId !== sessionId) throw RequestError.invalidParams(undefined, "Session mismatch"); + switch (params.action) { + case "list": return {version: 1, data: await client.threadAttachmentList({threadId: sessionId, ...(params.cursor === undefined ? {} : {cursor: params.cursor}), ...(params.limit === undefined ? {} : {limit: params.limit})})}; + case "add": return {version: 1, data: await client.threadAttachmentAdd({threadId: sessionId, attachmentType: params.attachmentType, identityKey: params.identityKey, payload: params.payload})}; + case "remove": return {version: 1, data: await client.threadAttachmentRemove({threadId: sessionId, attachmentType: params.attachmentType, identityKey: params.identityKey})}; + } +} diff --git a/src/SessionFileRevert.ts b/src/SessionFileRevert.ts new file mode 100644 index 000000000..5ba556d2a --- /dev/null +++ b/src/SessionFileRevert.ts @@ -0,0 +1,300 @@ +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { lstat, readFile, realpath } from "node:fs/promises"; +import path from "node:path"; +import { RequestError } from "@agentclientprotocol/sdk"; +import { z } from "zod"; +import type { Thread, ThreadItem } from "./app-server/v2"; +import { + createAddedFileGitPatch, + createDeletedFileGitPatch, + createUpdateGitPatch, +} from "./GitPatch"; + +export const SESSION_FILE_REVERT_METHOD = "_session/files/revert"; +export const sessionFileRevertParser = z + .object({ + sessionId: z.string().trim().min(1), + toolCallId: z.string().trim().min(1), + dryRun: z.boolean(), + previewToken: z + .string() + .regex(/^sha256:[a-f0-9]{64}$/) + .optional(), + }) + .strict() + .refine( + (p) => p.dryRun || p.previewToken !== undefined, + "A restore requires its preview token", + ); +export type SessionFileRevertRequest = z.infer; +type NativeFileChange = Extract; +export type FileRevertSession = { + sessionId: string; + cwd: string; + additionalDirectories: string[]; +}; +export type FileRevertResult = { + version: 1; + dryRun: boolean; + canRevert: boolean; + reverted: boolean; + paths: string[]; + previewToken?: string; + reason?: string; +}; +export function fileRevertCapability() { + return { + version: 1, + method: SESSION_FILE_REVERT_METHOD, + dryRun: true, + previewTokenRequired: true, + scope: "nativeFileChangeTool", + requiresGit: true, + changesConversation: false, + coverage: + "Recorded text patches only; not shell changes or a whole-workspace checkpoint", + }; +} + +const hash = (value: string | Buffer) => + createHash("sha256").update(value).digest("hex"); +const MAX_BYTES = 8 * 1024 * 1024; +function inside(root: string, candidate: string): boolean { + const relative = path.relative(root, candidate); + return ( + relative !== "" && + !path.isAbsolute(relative) && + relative !== ".." && + !relative.startsWith(`..${path.sep}`) + ); +} + +/** Ignore inherited alternate-index/worktree knobs. This operation never stages + * files and never changes HEAD, branches, hooks, or the user's Git index. */ +async function git( + cwd: string, + args: string[], + input?: string, +): Promise<{ code: number | null; stdout: string }> { + const env = Object.fromEntries( + Object.entries(process.env).filter( + ([key]) => !key.toUpperCase().startsWith("GIT_"), + ), + ); + return new Promise((resolve, reject) => { + const child = spawn("git", args, { + cwd, + env, + windowsHide: true, + stdio: ["pipe", "pipe", "pipe"], + }); + const chunks: Buffer[] = []; + let size = 0; + const timer = setTimeout(() => child.kill(), 15_000); + child.stdout.on("data", (chunk: Buffer) => { + size += chunk.length; + if (size <= MAX_BYTES) chunks.push(chunk); + else child.kill(); + }); + child.stderr.resume(); + child.stdin.on("error", () => {}); + child.once("error", (error) => { + clearTimeout(timer); + reject(error); + }); + child.once("close", (code) => { + clearTimeout(timer); + resolve({ code, stdout: Buffer.concat(chunks).toString("utf8") }); + }); + child.stdin.end(input); + }); +} + +async function checkedPath( + root: string, + roots: string[], + value: string, +): Promise<{ absolute: string; relative: string }> { + const absolute = path.resolve(value); + if ( + !inside(root, absolute) || + !roots.some((workspace) => inside(workspace, absolute)) + ) + throw new Error("outside_workspace"); + const relative = path.relative(root, absolute); + const parts = relative.split(path.sep); + if (parts.some((part) => part.toLowerCase() === ".git" || part.includes(":"))) + throw new Error("unsafe_path"); + let walk = root; + for (let i = 0; i < parts.length; i++) { + walk = path.join(walk, parts[i]!); + try { + const stat = await lstat(walk); + if ( + stat.isSymbolicLink() || + (i < parts.length - 1 ? !stat.isDirectory() : !stat.isFile()) + ) + throw new Error("unsupported_file_type"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") break; + throw error; + } + } + return { absolute, relative: relative.split(path.sep).join("/") }; +} + +async function fingerprintFiles(files: string[]): Promise { + return Promise.all( + files.map(async (file) => { + try { + const stat = await lstat(file); + if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_BYTES) + throw new Error("unsupported_file_type_or_size"); + return `${file}\0${stat.mode}\0${hash(await readFile(file))}`; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") + return `${file}\0missing`; + throw error; + } + }), + ); +} + +/** Hold the session AND overlapping-workspace mutation fence around this whole + * call. External editors are not controlled by the adapter; Git rejects patch + * conflicts, but this is not a cross-process filesystem transaction. */ +export async function revertSessionFiles( + value: unknown, + session: FileRevertSession, + readHistory: () => Promise<{ thread: Thread }>, +): Promise { + const request = sessionFileRevertParser.parse(value); + if (request.sessionId !== session.sessionId) + throw RequestError.invalidParams(undefined, "Session mismatch"); + const base: FileRevertResult = { + version: 1, + dryRun: request.dryRun, + canRevert: false, + reverted: false, + paths: [], + }; + const { thread } = await readHistory(); + if (thread.id !== session.sessionId) + throw RequestError.invalidParams(undefined, "History session mismatch"); + const matches = thread.turns + .flatMap((turn) => turn.items) + .filter( + (item): item is NativeFileChange => + item.type === "fileChange" && item.id === request.toolCallId, + ); + if (matches.length !== 1 || matches[0]!.status !== "completed") + return { ...base, reason: "completed_patch_not_found" }; + const changes = matches[0]!.changes; + if (!Array.isArray(changes) || changes.length === 0 || changes.length > 64) + return {...base, reason: "unsupported_patch_size"}; + // Validate every native change before constructing any patch. Generated types cannot + // validate runtime JSON from an older/newer native process. + for (const change of changes as unknown[]) { + if (change === null || typeof change !== "object") return {...base, reason: "unsupported_patch"}; + const value = change as {path?: unknown; kind?: {type?: unknown; move_path?: unknown}; diff?: unknown}; + if (typeof value.path !== "string" || !value.path || typeof value.diff !== "string" + || value.kind === null || typeof value.kind !== "object" + || !["add", "delete", "update"].includes(String(value.kind.type)) + || (value.kind.type === "update" && value.kind.move_path != null && typeof value.kind.move_path !== "string")) { + return {...base, reason: "unsupported_patch"}; + } + } + const found = await git(session.cwd, ["rev-parse", "--show-toplevel"]); + if (found.code !== 0) return { ...base, reason: "not_git_repository" }; + const root = await realpath(found.stdout.trim()); + const roots = await Promise.all( + [session.cwd, ...session.additionalDirectories].map((value) => + realpath(value), + ), + ); + let patch = ""; + const paths = new Set(); + try { + for (const change of changes) { + const source = await checkedPath( + root, + roots, + path.resolve(session.cwd, change.path), + ); + paths.add(source.absolute); + let part: string | null; + switch (change.kind.type) { + case "add": + part = createAddedFileGitPatch(source.relative, change.diff); + break; + case "delete": + part = createDeletedFileGitPatch(source.relative, change.diff); + break; + case "update": { + const target = change.kind.move_path + ? await checkedPath( + root, + roots, + path.resolve(session.cwd, change.kind.move_path), + ) + : source; + paths.add(target.absolute); + part = createUpdateGitPatch( + source.relative, + target.relative, + change.diff, + ); + break; + } + default: + return { ...base, reason: "unsupported_patch" }; + } + if (part === null) return { ...base, reason: "unsupported_patch" }; + patch += part; + if (Buffer.byteLength(patch, "utf8") > MAX_BYTES) + return { ...base, reason: "unsupported_patch_size" }; + } + base.paths = [...paths].sort(); + const before = await fingerprintFiles(base.paths); + const previewToken = `sha256:${hash(JSON.stringify([root, session.sessionId, request.toolCallId, patch, before]))}`; + if (!request.dryRun && request.previewToken !== previewToken) + return { ...base, reason: "stale_preview" }; + const check = await git( + root, + ["apply", "--reverse", "--check", "--whitespace=nowarn", "-"], + patch, + ); + if (check.code !== 0) return { ...base, reason: "patch_conflict" }; + if (request.dryRun) return { ...base, canRevert: true, previewToken }; + // Validate nodes again immediately before applying, including parents. + for (const file of base.paths) await checkedPath(root, roots, file); + if ( + JSON.stringify(before) !== + JSON.stringify(await fingerprintFiles(base.paths)) + ) + return { ...base, reason: "stale_preview" }; + const applied = await git( + root, + ["apply", "--reverse", "--whitespace=nowarn", "-"], + patch, + ); + if (applied.code !== 0) + return { ...base, reason: "restore_outcome_unknown" }; + return { ...base, canRevert: true, reverted: true }; + } catch (error) { + // Never include file contents or provider stderr in a failure response. + if ( + error instanceof Error && + [ + "outside_workspace", + "unsafe_path", + "unsupported_file_type", + "unsupported_file_type_or_size", + ].includes(error.message) + ) { + return { ...base, reason: error.message }; + } + throw error; + } +} diff --git a/src/SessionQueue.ts b/src/SessionQueue.ts new file mode 100644 index 000000000..dd0de086f --- /dev/null +++ b/src/SessionQueue.ts @@ -0,0 +1,191 @@ +import {RequestError} from "@agentclientprotocol/sdk"; +import {z} from "zod"; +import type {Turn} from "./app-server/v2/Turn"; + +export const SESSION_QUEUE_METHOD = "_session/queue"; +export const SESSION_QUEUE_ACTIONS = ["list", "add", "update", "delete", "reorder", "start"] as const; +export type SessionQueueAction = typeof SESSION_QUEUE_ACTIONS[number]; + +// A conservative tested floor, NOT a claim about the first native release. +// Source: codex-rs/app-server-protocol/src/protocol/v2/thread.rs:926-1027; +// experimental registrations: protocol/common.rs:630-664. Not generated types. +export const SESSION_QUEUE_MIN_NATIVE_VERSION = "0.160.1"; + +const identifier = z.string().refine(value => value.trim().length > 0 && !/[\u0000-\u001f\u007f]/u.test(value)); +const byteOffset = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER); +const textElement = z.strictObject({ + byteRange: z.strictObject({start: byteOffset, end: byteOffset}), + placeholder: z.string().nullable().optional(), +}); +const detail = z.enum(["auto", "low", "high", "original"]).nullable().optional(); + +// Preserve native UserInput, including snake_case text_elements and omitted +// serde-default fields. No ACP text/image conversion, defaults, or dropped keys. +// Source: protocol/v2/turn.rs:382-474. New variants require an explicit review. +const nativeInput = z.union([ + z.strictObject({type: z.literal("text"), text: z.string(), text_elements: z.array(textElement).optional()}), + z.strictObject({type: z.literal("image"), url: z.string(), detail}), + z.strictObject({type: z.literal("image"), fileId: z.string(), detail}), + z.strictObject({type: z.literal("localImage"), path: z.string(), detail}), + z.strictObject({type: z.literal("audio"), url: z.string()}), + z.strictObject({type: z.literal("localAudio"), path: z.string()}), + z.strictObject({type: z.literal("skill"), name: z.string(), path: z.string()}), + z.strictObject({type: z.literal("mention"), name: z.string(), path: z.string()}), +]); +export type NativeQueueInput = z.infer; + +const session = {sessionId: identifier}; +const requestSchema = z.discriminatedUnion("action", [ + z.strictObject({ + ...session, action: z.literal("list"), cursor: z.string().nullable().optional(), + limit: z.number().int().min(0).max(0xffff_ffff).nullable().optional(), + }), + z.strictObject({ + ...session, action: z.literal("add"), input: z.array(nativeInput), clientUserMessageId: identifier, + }), + z.strictObject({ + ...session, action: z.literal("update"), queuedSubmissionId: identifier, input: z.array(nativeInput), + }), + z.strictObject({...session, action: z.literal("delete"), queuedSubmissionId: identifier}), + z.strictObject({...session, action: z.literal("reorder"), queuedSubmissionIds: z.array(identifier)}), + z.strictObject({...session, action: z.literal("start"), queuedSubmissionId: identifier.nullable().optional()}), +]); +export type SessionQueueRequest = z.infer; + +export function parseSessionQueueRequest(value: unknown): SessionQueueRequest { + const parsed = requestSchema.safeParse(value); + if (!parsed.success) { + // Do not echo prompt content, attachment URLs, or unknown injected fields. + throw RequestError.invalidParams(undefined, "Invalid session queue request"); + } + return parsed.data; +} + +export type NativeQueuedSubmission = { + id: string; + input: NativeQueueInput[]; + clientUserMessageId: string; + [key: string]: unknown; +}; +export type NativeQueueListResponse = {data: NativeQueuedSubmission[]; nextCursor: string | null; [key: string]: unknown}; +export type NativeQueueWriteResponse = {queuedSubmission: NativeQueuedSubmission; [key: string]: unknown}; +export type NativeQueueDeleteResponse = {deleted: boolean; [key: string]: unknown}; +export type NativeQueueReorderResponse = Record; +export type NativeQueueStartResponse = {turn: Turn; [key: string]: unknown}; + +type NativeParams = + Omit, "sessionId" | "action"> & {threadId: string}; + +/** No caller-selectable RPC method or free-form native params escape hatch. */ +export interface SessionQueueNative { + list(params: NativeParams<"list">): Promise; + add(params: NativeParams<"add">): Promise; + update(params: NativeParams<"update">): Promise; + delete(params: NativeParams<"delete">): Promise; + reorder(params: NativeParams<"reorder">): Promise; + /** This starts real generation. The owning server must attach turn handlers first. */ + start(params: NativeParams<"start">): Promise; +} + +export type SessionQueueSupport = { + readonly nativeVersion: string | null; + readonly actions: readonly SessionQueueAction[]; +}; + +function eligibleNativeVersion(version: string | null | undefined): boolean { + // Only an actual normalized stable native version. An npm package version, + // missing version, or unverified prerelease is not support evidence. + const match = version?.match(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/u); + if (!match) return false; + const [major, minor, patch] = match.slice(1).map(Number); + if (![major, minor, patch].every(value => Number.isSafeInteger(value))) return false; + return major! > 0 || minor! > 160 || (minor === 160 && patch! >= 1); +} + +/** + * The integrator supplies per-action POSITIVE evidence from the running native + * process (e.g. its actual experimental schema, or a side-effect-free parameter + * validation probe). A list-only probe proves only list. Never call a valid + * add/update/delete/reorder/start to probe, and never treat an arbitrary -32602, + * auth/store/network error, or package version as proof of method support. + * Cache only for that native process generation; re-probe on restart/override. + */ +export async function probeSessionQueueSupport( + nativeVersion: string | null | undefined, + probeAction: (action: SessionQueueAction) => Promise, +): Promise { + const actions: SessionQueueAction[] = []; + if (eligibleNativeVersion(nativeVersion)) { + for (const action of SESSION_QUEUE_ACTIONS) { + try { + if (await probeAction(action) === true) actions.push(action); + } catch { + // Absence of affirmative evidence leaves this action unadvertised. + } + } + } + return Object.freeze({nativeVersion: nativeVersion ?? null, actions: Object.freeze(actions)}); +} + +export function sessionQueueCapability(support: SessionQueueSupport) { + if (!eligibleNativeVersion(support.nativeVersion)) return undefined; + const actions = SESSION_QUEUE_ACTIONS.filter(action => support.actions.includes(action)); + if (actions.length === 0) return undefined; + return {version: 1 as const, method: SESSION_QUEUE_METHOD, actions}; +} + +export type SessionQueueResponse = + | {status: "unsupported"} + | {status: "ok"; result: NativeQueueListResponse | NativeQueueWriteResponse | + NativeQueueDeleteResponse | NativeQueueReorderResponse | NativeQueueStartResponse}; + +/** + * Must run INSIDE the server's lifecycle reservation for this exact session and + * native process: serialize with prompt, rewind, close, delete, provider change, + * and queue operations until the native promise settles. A timeout/transport + * loss does not imply no mutation; the caller must reconcile, never auto-retry. + * This module has no transport, process launching, locks, or event ownership. + */ +export async function runSessionQueue( + value: unknown, + dependencies: {sessionId: string; support: SessionQueueSupport; native: SessionQueueNative}, +): Promise { + const request = parseSessionQueueRequest(value); + if (request.sessionId !== dependencies.sessionId) { + throw RequestError.invalidParams(undefined, "Queue request does not target the bound session"); + } + const capability = sessionQueueCapability(dependencies.support); + if (!capability?.actions.includes(request.action)) return {status: "unsupported"}; + const threadId = dependencies.sessionId; + const native = dependencies.native; + try { + switch (request.action) { + case "list": + return {status: "ok", result: await native.list({ + threadId, + ...("cursor" in request ? {cursor: request.cursor} : {}), + ...("limit" in request ? {limit: request.limit} : {}), + })}; + case "add": + return {status: "ok", result: await native.add({threadId, input: request.input, clientUserMessageId: request.clientUserMessageId})}; + case "update": + return {status: "ok", result: await native.update({threadId, queuedSubmissionId: request.queuedSubmissionId, input: request.input})}; + case "delete": + return {status: "ok", result: await native.delete({threadId, queuedSubmissionId: request.queuedSubmissionId})}; + case "reorder": + return {status: "ok", result: await native.reorder({threadId, queuedSubmissionIds: request.queuedSubmissionIds})}; + case "start": + return {status: "ok", result: await native.start({ + threadId, + ...("queuedSubmissionId" in request ? {queuedSubmissionId: request.queuedSubmissionId} : {}), + })}; + } + } catch (error) { + // A definitive JSON-RPC method-not-found is safe to report as unsupported. + // No other error is converted to success/unsupported and nothing retries. + if (typeof error === "object" && error !== null && "code" in error && error.code === -32601) { + return {status: "unsupported"}; + } + throw error; + } +} diff --git a/src/SessionRuntime.ts b/src/SessionRuntime.ts new file mode 100644 index 000000000..7b0c692f9 --- /dev/null +++ b/src/SessionRuntime.ts @@ -0,0 +1,119 @@ +import {RequestError} from "@agentclientprotocol/sdk"; +import {z} from "zod"; +import type {CodexAcpClient} from "./CodexAcpClient"; +import type {SessionState} from "./CodexAcpServer"; +import {listMcpServerStatus} from "./mcp/McpServerStatusList"; + +export const RUNTIME_READ_METHOD = "_session/runtime/read"; +export const RUNTIME_CONTROL_METHOD = "_session/runtime/control"; +export const runtimeReadParser = z.object({ + sessionId: z.string().trim().min(1), + resource: z.enum(["context", "usage", "mcp", "commands", "plugins"]), +}).strict(); +export const runtimeControlParser = z.object({ + sessionId: z.string().trim().min(1), + action: z.enum(["reloadSkills", "reconnectMcp", "reloadPlugins"]), +}).strict(); +export type RuntimeReadRequest = z.infer; +export type RuntimeControlRequest = z.infer; +export type RuntimeResponse = + | {version: 1, status: "ok", data: unknown} + | {version: 1, status: "unavailable", reason: "timeout" | "cancelled" | "stale"}; + +export function runtimeCapability() { + return { + version: 1, + readMethod: RUNTIME_READ_METHOD, + controlMethod: RUNTIME_CONTROL_METHOD, + reads: ["context", "usage", "mcp", "commands", "plugins"], + controls: ["reloadSkills", "reconnectMcp", "reloadPlugins"], + }; +} + +export type RuntimeSession = Pick; +type RuntimeClient = Pick; + +/** Do not synthesize prompt categories from aggregate token usage. Null means + * the native runtime has not supplied the measurement, not zero tokens. */ +export async function readRuntime( + client: RuntimeClient, + session: RuntimeSession, + request: RuntimeReadRequest, + signal: AbortSignal, + isCurrent: () => boolean = () => true, + timeoutMs = 5000, +): Promise { + if (request.sessionId !== session.sessionId) throw RequestError.invalidParams(undefined, "Session mismatch"); + if (signal.aborted) return {version: 1, status: "unavailable", reason: "cancelled"}; + if (!isCurrent()) return {version: 1, status: "unavailable", reason: "stale"}; + const operation = async (): Promise => { + switch (request.resource) { + case "context": return { + model: session.currentModelId, + totalTokens: session.lastTokenUsage?.totalTokens ?? null, + maxTokens: session.modelContextWindow, + categories: null, + source: "last_native_usage_event", + }; + case "usage": return {last: session.lastTokenUsage, total: session.totalTokenUsage, rateLimits: session.rateLimits}; + case "plugins": return await client.installedPlugins([session.cwd, ...session.additionalDirectories]); + case "commands": return await client.listSkills({cwds: [session.cwd, ...session.additionalDirectories]}); + case "mcp": { + // The existing paginated reader takes only listMcpServers in practice. + const servers = await listMcpServerStatus(client as CodexAcpClient, session.sessionId); + return servers.map(server => ({ + name: server.name, + status: server.runtimeStatus, + authStatus: server.authStatus, + pluginId: server.pluginId, + serverInfo: server.serverInfo, + toolNames: Object.keys(server.tools), + })); + } + } + }; + let timer: ReturnType | undefined; + let onAbort: (() => void) | undefined; + try { + const result = await Promise.race([ + operation().then(data => ({version: 1, status: "ok", data})), + new Promise(resolve => { + timer = setTimeout(() => resolve({version: 1, status: "unavailable", reason: "timeout"}), timeoutMs); + timer.unref?.(); + }), + new Promise(resolve => { + onAbort = () => resolve({version: 1, status: "unavailable", reason: "cancelled"}); + signal.addEventListener("abort", onAbort, {once: true}); + }), + ]); + return isCurrent() ? result : {version: 1, status: "unavailable", reason: "stale"}; + } finally { + if (timer) clearTimeout(timer); + if (onAbort) signal.removeEventListener("abort", onAbort); + } +} + +/** The server must retain its lifecycle lease until native completion. + * reloadMcpServers is process-wide: use the provider barrier, not just a + * per-session guard, and do not claim the connections are ready from its ack. */ +export async function controlRuntime( + client: RuntimeClient, + session: RuntimeSession, + request: RuntimeControlRequest, +): Promise { + if (request.sessionId !== session.sessionId) throw RequestError.invalidParams(undefined, "Session mismatch"); + switch (request.action) { + case "reloadSkills": return { + version: 1, status: "ok", + data: await client.listSkills({cwds: [session.cwd, ...session.additionalDirectories], forceReload: true}), + }; + case "reloadPlugins": + return {version: 1, status: "ok", data: {...await client.reconcilePlugins(), scope: "provider", runtimeReady: false}}; + case "reconnectMcp": + await client.reloadMcpServers(); + return {version: 1, status: "ok", data: {completed: true, scope: "provider", connectionsReady: false}}; + } +} diff --git a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts index 7fd3ab720..7ae250778 100644 --- a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts +++ b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts @@ -1,3 +1,4 @@ +import path from "node:path"; // noinspection ES6RedundantAwait import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; @@ -498,7 +499,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { }); expect(extraRootsSetSpy).toHaveBeenCalledWith({ - extraRoots: ["/skills/one/.agents/skills", "/skills/two/.agents/skills"], + extraRoots: [path.join("/skills/one", ".agents", "skills"), path.join("/skills/two", ".agents", "skills")], }); expect(extraRootsSetSpy.mock.invocationCallOrder[0]!).toBeLessThan(threadStartSpy.mock.invocationCallOrder[0]!); expect(listSkillsSpy).not.toHaveBeenCalled(); @@ -533,7 +534,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { expect(session.additionalDirectories).toEqual(["/workspace/extra"]); expect(extraRootsSetSpy).toHaveBeenCalledWith({ - extraRoots: ["/workspace/extra/.agents/skills"], + extraRoots: [path.join("/workspace/extra", ".agents", "skills")], }); expect(extraRootsSetSpy.mock.invocationCallOrder[0]!).toBeLessThan(threadStartSpy.mock.invocationCallOrder[0]!); expect(listSkillsSpy).not.toHaveBeenCalled(); @@ -1384,7 +1385,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { }); expect(extraRootsSetSpy).toHaveBeenCalledWith({ - extraRoots: ["/workspace/extra/.agents/skills"], + extraRoots: [path.join("/workspace/extra", ".agents", "skills")], }); expect(listSkillsSpy).not.toHaveBeenCalledWith(expect.objectContaining({forceReload: true})); expect(turnStartSpy.mock.calls[0]![0].sandboxPolicy).toMatchObject({ diff --git a/src/__tests__/CodexACPAgent/initialize.test.ts b/src/__tests__/CodexACPAgent/initialize.test.ts index ac82d3ba0..7574fb024 100644 --- a/src/__tests__/CodexACPAgent/initialize.test.ts +++ b/src/__tests__/CodexACPAgent/initialize.test.ts @@ -1,3 +1,7 @@ +import {fileRevertCapability} from "../../SessionFileRevert"; +import {sessionDiscoveryCapability} from "../../SessionDiscovery"; +import {archiveCapability} from "../../SessionArchive"; +import {runtimeCapability} from "../../SessionRuntime"; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { CodexAcpServer } from '../../CodexAcpServer'; import * as acp from '@agentclientprotocol/sdk'; @@ -67,8 +71,13 @@ describe('CodexACPAgent - initialize', () => { }, authMethods: getCodexAuthMethods(), _meta: { + runtime: runtimeCapability(), + archive: archiveCapability(), + fileRevert: fileRevertCapability(), + discovery: sessionDiscoveryCapability(), steering: { supported: true, + idleBehavior: ["promptRequired"], }, }, }); @@ -80,8 +89,13 @@ describe('CodexACPAgent - initialize', () => { clientCapabilities: {_meta: {jetbrains: {air: {version: 1, capabilities: []}}}}, }); expect(result._meta).toEqual({ + runtime: runtimeCapability(), + archive: archiveCapability(), + fileRevert: fileRevertCapability(), + discovery: sessionDiscoveryCapability(), steering: { supported: true, + idleBehavior: ["promptRequired"], }, jetbrains: { air: { diff --git a/src/__tests__/CodexACPAgent/session-rewind-lifecycle.test.ts b/src/__tests__/CodexACPAgent/session-rewind-lifecycle.test.ts index 99adaf5de..a707c6709 100644 --- a/src/__tests__/CodexACPAgent/session-rewind-lifecycle.test.ts +++ b/src/__tests__/CodexACPAgent/session-rewind-lifecycle.test.ts @@ -8,7 +8,7 @@ import type {CodexAcpServer, SessionState} from "../../CodexAcpServer"; const sessionId = "session-id"; const request = {sessionId, beforeMessage:{messageId:"user",messageFingerprint:`sha256:${createHash("sha256").update("original").digest("hex")}`,messageOccurrence:1}}; -const steer = {sessionId,prompt:[{type:"text",text:"followup"}]}; +const steer = {sessionId,prompt:[{type:"text",text:"followup"}],_meta:{steering:{idleBehavior:"promptRequired"}}}; function deferred() {let resolve!: (value:T)=>void; const promise=new Promise(r=>{resolve=r;});return {promise,resolve};} function setup() { const fixture=createCodexMockTestFixture(); @@ -33,6 +33,7 @@ describe("rewind lifecycle",()=>{ ()=>agent.extMethod(SESSION_STEERING_METHOD,steer), ()=>agent.setSessionMode({sessionId,modeId:"read-only"}), ()=>agent.setSessionConfigOption({sessionId,configId:"model",value:"m"}), + ()=>agent.readSessionRuntime({sessionId,resource:"context"}), ]) await expect(attempt()).rejects.toThrow(); gate.resolve({rewound:true});await pending; call.mockRejectedValueOnce(new Error("invalid target")); @@ -92,7 +93,20 @@ describe("rewind lifecycle",()=>{ }); }); -describe("steering lifecycle",()=>{ +describe("opt-in steering",()=>{ + it("returns promptRequired while idle without calling turn/start",async()=>{ + const {agent,native}=setup();const start=vi.spyOn(native,"turnStart"); + await expect(agent.extMethod(SESSION_STEERING_METHOD,steer)).resolves.toEqual({outcome:"promptRequired"}); + expect(start).not.toHaveBeenCalled(); + }); + it("returns promptRequired if the native turn ends before steer is accepted",async()=>{ + const {agent,native,state}=setup();state.currentTurnId="active"; + const gate=deferred();const call=vi.spyOn(native,"turnSteer").mockReturnValue(gate.promise); + const pending=agent.extMethod(SESSION_STEERING_METHOD,steer); + await vi.waitFor(()=>expect(call).toHaveBeenCalledOnce()); + state.currentTurnId=null;gate.resolve(Promise.reject(Error("no active turn to steer")) as never); + await expect(pending).resolves.toEqual({outcome:"promptRequired"}); + }); it("blocks rewind while a steer is already in flight",async()=>{ const {agent,native,client,state}=setup();state.currentTurnId="active"; const gate=deferred();const call=vi.spyOn(native,"turnSteer").mockReturnValue(gate.promise); @@ -102,3 +116,102 @@ describe("steering lifecycle",()=>{ expect(revert).not.toHaveBeenCalled();gate.resolve({} as never);await expect(pending).resolves.toEqual({outcome:"injected"}); }); }); + +describe("runtime lifecycle",()=>{ + it("requires provider idle for MCP reconnect, and fences all sessions while reconnecting",async()=>{ + const {agent,client}=setup();const gate=deferred();const reload=vi.spyOn(client,"reloadMcpServers").mockReturnValue(gate.promise); + const pending=agent.controlSessionRuntime({sessionId,action:"reconnectMcp"});await vi.waitFor(()=>expect(reload).toHaveBeenCalledOnce()); + await expect(agent.prompt({sessionId,prompt:[]})).rejects.toMatchObject({data:expect.stringContaining("Runtime control")}); + await expect(rewind(agent)).rejects.toMatchObject({data:expect.stringContaining("Runtime control")}); + await expect(agent.closeSession({sessionId})).rejects.toMatchObject({data:expect.stringContaining("Runtime control")}); + await expect(agent.setProvider({providerId:OPENAI_PROVIDER_ID,apiType:"openai",baseUrl:"https://example.test"})).rejects.toMatchObject({data:expect.stringContaining("Runtime control")}); + gate.resolve();await expect(pending).resolves.toMatchObject({status:"ok",data:{scope:"provider",connectionsReady:false}}); + }); + it("rejects reconnect while a prompt is active",async()=>{ + const {agent,fixture,client,native}=setup();mockPromptTurn(fixture,sessionId);const finish=deferred(); + vi.spyOn(native,"awaitTurnCompleted").mockReturnValue(finish.promise); + const pending=agent.prompt({sessionId,prompt:[{type:"text",text:"running"}]}); + await vi.waitFor(()=>expect(agent.getSessionState(sessionId).currentTurnId).toBe("turn-id")); + const reload=vi.spyOn(client,"reloadMcpServers");await expect(agent.controlSessionRuntime({sessionId,action:"reconnectMcp"})).rejects.toMatchObject({data:expect.stringContaining("Provider is busy")});expect(reload).not.toHaveBeenCalled(); + await agent.closeSession({sessionId});await pending; + }); + it("republishes available commands after reloadSkills and releases the control fence",async()=>{ + const {agent,client}=setup();const skills=vi.spyOn(client,"listSkills").mockResolvedValue({data:[]}); + const publish=vi.spyOn(agent as unknown as {publishAvailableCommands:()=>Promise},"publishAvailableCommands").mockResolvedValue(); + await agent.controlSessionRuntime({sessionId,action:"reloadSkills"});expect(skills).toHaveBeenCalledWith({cwds:["/test/cwd"],forceReload:true});expect(publish).toHaveBeenCalledOnce(); + await expect(agent.extMethod(SESSION_STEERING_METHOD,steer)).resolves.toEqual({outcome:"promptRequired"}); + }); +}); + + +describe("native pending queue admission", () => { + it.each(["load", "resume"] as const)("does not reopen after close during %s queue preflight", async method => { + const {agent,client,native} = setup(); + client.queueSupport = {nativeVersion:"0.160.1",actions:["list"]}; + const pending = deferred<{data:[],nextCursor:null}>(); + const list = vi.fn().mockReturnValue(pending.promise); + vi.spyOn(native,"queueNative").mockReturnValue({list} as never); + const resume = vi.spyOn(client,"resumeSession"); + const load = vi.spyOn(client,"loadSession"); + const opening = method === "load" ? agent.loadSession({sessionId,cwd:"/test/cwd",mcpServers:[]}) : agent.resumeSession({sessionId,cwd:"/test/cwd",mcpServers:[]}); + const rejection = expect(opening).rejects.toMatchObject({data:expect.stringContaining("changed while checking")}); + await vi.waitFor(()=>expect(list).toHaveBeenCalledOnce()); + await agent.closeSession({sessionId}); + pending.resolve({data:[],nextCursor:null}); + await rejection; + expect(resume).not.toHaveBeenCalled();expect(load).not.toHaveBeenCalled(); + }); + it("does not read or resume a session while close already holds its fence", async () => { + const {agent,client,native} = setup(); + client.queueSupport={nativeVersion:"0.160.1",actions:["list"]}; + const release=deferred();vi.spyOn(client,"closeSession").mockReturnValue(release.promise); + const closing=agent.closeSession({sessionId}); + const list=vi.fn();vi.spyOn(native,"queueNative").mockReturnValue({list} as never); + await expect(agent.resumeSession({sessionId,cwd:"/test/cwd",mcpServers:[]})).rejects.toMatchObject({data:expect.stringContaining("closing")}); + expect(list).not.toHaveBeenCalled();release.resolve();await closing; + }); + it("rejects persisted pending work before native resume can auto-dispatch", async () => { + const {agent,client,native}=setup();client.queueSupport={nativeVersion:"0.160.1",actions:["list"]}; + vi.spyOn(native,"queueNative").mockReturnValue({list:vi.fn().mockResolvedValue({data:[{id:"queued"}],nextCursor:null})} as never); + const resume=vi.spyOn(client,"resumeSession"); + await expect(agent.resumeSession({sessionId,cwd:"/test/cwd",mcpServers:[]})).rejects.toMatchObject({data:expect.stringContaining("pending queue")}); + expect(resume).not.toHaveBeenCalled(); + }); +}); + + +describe("provider control recovery", () => { + it("invalidates sessions after a hung control, keeps close reachable and rejects late installation", async () => { + vi.useFakeTimers(); + try { + const {agent,client}=setup(); + const native = deferred(); + vi.spyOn(client,"reloadMcpServers").mockReturnValue(native.promise); + const pending=agent.controlSessionRuntime({sessionId,action:"reconnectMcp"}); + const failure=expect(pending).rejects.toThrow("timed out"); + await vi.advanceTimersByTimeAsync(30_000); + await failure; + await expect(agent.closeSession({sessionId})).resolves.toEqual({}); + await expect(agent.newSession({cwd:"/test/cwd",mcpServers:[]})).rejects.toMatchObject({data:expect.stringContaining("invalidated")}); + await expect(agent.setProvider({providerId:OPENAI_PROVIDER_ID,apiType:"openai",baseUrl:"https://example.test"})).rejects.toMatchObject({data:expect.stringContaining("invalidated")}); + native.resolve();await Promise.resolve(); + expect(()=>agent.getSessionState(sessionId)).toThrow(); + } finally { vi.useRealTimers(); } + }); + it("reserves creation before an asynchronous new session can install", async () => { + const {agent,client,state}=setup(); + const created=deferred(); + const open=vi.spyOn(agent as unknown as {getOrCreateSession:()=>Promise},"getOrCreateSession").mockReturnValue(created.promise); + const pending=agent.newSession({cwd:"/test/cwd",mcpServers:[]}); + await vi.waitFor(()=>expect(open).toHaveBeenCalledOnce()); + const reload=vi.spyOn(client,"reloadMcpServers"); + await expect(agent.controlSessionRuntime({sessionId,action:"reconnectMcp"})).rejects.toMatchObject({data:expect.stringContaining("Provider is busy")}); + await expect(agent.revertFiles({sessionId,toolCallId:"file",dryRun:true})).rejects.toMatchObject({data:expect.stringContaining("Provider is busy")}); + await expect(agent.setProvider({providerId:OPENAI_PROVIDER_ID,apiType:"openai",baseUrl:"https://example.test"})).rejects.toThrow(); + expect(reload).not.toHaveBeenCalled(); + created.resolve([sessionId,{currentModelId:state.currentModelId,availableModels:[]},{currentModeId:"agent",availableModes:[]}] as never); + await pending; + reload.mockResolvedValue(); + await expect(agent.controlSessionRuntime({sessionId,action:"reconnectMcp"})).resolves.toMatchObject({status:"ok"}); + }); +}); diff --git a/src/__tests__/CodexProcessTermination.failures.test.ts b/src/__tests__/CodexProcessTermination.failures.test.ts new file mode 100644 index 000000000..7bead11fd --- /dev/null +++ b/src/__tests__/CodexProcessTermination.failures.test.ts @@ -0,0 +1,152 @@ +import {EventEmitter} from "node:events"; +import {PassThrough} from "node:stream"; +import type {ChildProcessWithoutNullStreams} from "node:child_process"; +import {afterEach, describe, expect, it, vi} from "vitest"; +import {startCodexConnection, terminateCodexConnection} from "../CodexJsonRpcConnection"; + +const mocks = vi.hoisted(() => ({spawn: vi.fn(), spawnSync: vi.fn(), dispose: vi.fn()})); +vi.mock("node:child_process", () => ({spawn: mocks.spawn, spawnSync: mocks.spawnSync})); +vi.mock("cross-spawn", () => ({default: mocks.spawn})); +vi.mock("vscode-jsonrpc/node", () => ({ + createMessageConnection: () => ({listen() {}, dispose: mocks.dispose}), +})); +vi.mock("../StdUtils", () => ({createJSONRPCReader() {}, createJSONRPCWriter() {}})); +vi.mock("../Logger", () => ({logger: {log: vi.fn()}})); + +function child(pid: number | undefined = 987654) { + return Object.assign(new EventEmitter(), { + pid: pid as number | undefined, exitCode: null as number | null, signalCode: null as NodeJS.Signals | null, + stdin: new PassThrough(), stdout: new PassThrough(), stderr: new PassThrough(), + stdio: [null, null, null, Object.assign(new PassThrough(), {unref: vi.fn()})], kill: vi.fn(), + }); +} +const platform = Object.getOwnPropertyDescriptor(process, "platform")!; +function usePlatform(value: string) { + Object.defineProperty(process, "platform", {...platform, value}); +} +afterEach(() => { + Object.defineProperty(process, "platform", platform); + vi.restoreAllMocks(); + vi.useRealTimers(); + mocks.spawn.mockReset(); + mocks.dispose.mockReset(); +}); + +describe("CodexProcessTermination failure boundaries", () => { + it.each(["linux", "win32"])("does not signal or wait for a nonexistent PID on %s", async platform => { + usePlatform(platform); + const provider = child(); + provider.pid = undefined; + mocks.spawn.mockReturnValue(provider); + const native = startCodexConnection("synthetic-provider"); + await terminateCodexConnection(native); + await terminateCodexConnection(native); + expect(mocks.spawn).toHaveBeenCalledTimes(1); + expect(provider.kill).not.toHaveBeenCalled(); + expect(mocks.dispose).toHaveBeenCalledTimes(1); + }); + + it("rejects unowned processes without signalling a PID or shared group", async () => { + const provider = child(); + const kill = vi.spyOn(process, "kill"); + await expect(terminateCodexConnection({ + process: provider as unknown as ChildProcessWithoutNullStreams, + connection: {dispose: mocks.dispose} as any, + })).rejects.toThrow("unowned"); + expect(kill).not.toHaveBeenCalled(); + expect(provider.kill).not.toHaveBeenCalled(); + expect(mocks.spawn).not.toHaveBeenCalled(); + }); + + it.each(["SIGTERM", "SIGKILL"] as const)("recognizes already signalled Windows exit (%s) without reusing its PID", async signal => { + usePlatform("win32"); + const provider = child(); + mocks.spawn.mockReturnValue(provider); + const native = startCodexConnection("synthetic-provider"); + provider.signalCode = signal; + await expect(terminateCodexConnection(native)).rejects.toThrow("tree state is unknown"); + expect(mocks.spawn).toHaveBeenCalledTimes(1); + }); + + it("does not treat launcher exit as successful taskkill and never retries the old PID", async () => { + usePlatform("win32"); + const provider = child(); + const killer = child(987655); + mocks.spawn.mockReturnValueOnce(provider).mockReturnValueOnce(killer); + const native = startCodexConnection("synthetic-provider"); + const termination = terminateCodexConnection(native); + const failure = expect(termination).rejects.toThrow("tree termination failed"); + provider.signalCode = "SIGKILL"; + killer.emit("exit", 1); + await failure; + expect(terminateCodexConnection(native)).toBe(termination); + expect(mocks.spawn).toHaveBeenCalledTimes(2); + expect(mocks.spawn.mock.calls[1]).toEqual([ + "taskkill", ["/PID", "987654", "/T", "/F"], {windowsHide: true, stdio: "ignore"}, + ]); + }); + + it("rejects a taskkill timeout and caches the failure", async () => { + vi.useFakeTimers(); + usePlatform("win32"); + const provider = child(); + const killer = child(987655); + mocks.spawn.mockReturnValueOnce(provider).mockReturnValueOnce(killer); + const native = startCodexConnection("synthetic-provider"); + const termination = terminateCodexConnection(native); + const failure = expect(termination).rejects.toThrow("timed out"); + await vi.advanceTimersByTimeAsync(5_000); + await failure; + expect(killer.kill).toHaveBeenCalledTimes(1); + expect(terminateCodexConnection(native)).toBe(termination); + expect(mocks.spawn).toHaveBeenCalledTimes(2); + }); + + it("does not report stopped when taskkill succeeded but no child exit was observed", async () => { + vi.useFakeTimers(); + usePlatform("win32"); + mocks.spawn.mockReturnValueOnce(child()).mockReturnValueOnce(child(987655)); + const native = startCodexConnection("synthetic-provider"); + const termination = terminateCodexConnection(native); + const failure = expect(termination).rejects.toThrow("timed out"); + mocks.spawn.mock.results[1]!.value.emit("exit", 0); + await vi.advanceTimersByTimeAsync(5_000); + await failure; + }); + + it.each([undefined, "synthetic-provider"])("owns an isolated POSIX group for launcher %s and only probes its PGID", async command => { + usePlatform("linux"); + const provider = child(); + mocks.spawn.mockReturnValue(provider); + const kill = vi.spyOn(process, "kill").mockImplementation(() => { + throw Object.assign(new Error("gone"), {code: "ESRCH"}); + }); + const native = startCodexConnection(command); + expect(mocks.spawn.mock.calls[0]![2]).toMatchObject({detached: true, stdio: ["pipe", "pipe", "pipe", "pipe"]}); + expect((provider.stdio[3] as PassThrough & {unref: ReturnType}).unref).toHaveBeenCalledOnce(); + const args = mocks.spawn.mock.calls[0]![1] as string[]; + if (command === undefined) expect(args.some(arg => /codex[/\\]bin[/\\]codex.js$/.test(arg))).toBe(true); + else expect(args).toContain(command); + provider.signalCode = "SIGKILL"; + await terminateCodexConnection(native); + expect(kill).toHaveBeenCalledExactlyOnceWith(-987654, 0); + expect(provider.kill).not.toHaveBeenCalled(); + }); + + it("rejects when a POSIX group remains after its leader exits; no destructive PID retry", async () => { + vi.useFakeTimers(); + usePlatform("linux"); + const provider = child(); + mocks.spawn.mockReturnValue(provider); + const kill = vi.spyOn(process, "kill").mockReturnValue(true); + const native = startCodexConnection("synthetic-provider"); + provider.exitCode = 0; + const termination = terminateCodexConnection(native); + const failure = expect(termination).rejects.toThrow("timed out"); + await vi.advanceTimersByTimeAsync(5_000); + await failure; + expect(kill.mock.calls.every(([pid, signal]) => pid === -987654 && signal === 0)).toBe(true); + expect(terminateCodexConnection(native)).toBe(termination); + expect(provider.listenerCount("exit")).toBe(2); // only the permanent logging/disposal listeners + }); +}); diff --git a/src/__tests__/CodexProcessTermination.test.ts b/src/__tests__/CodexProcessTermination.test.ts new file mode 100644 index 000000000..8b818bfa5 --- /dev/null +++ b/src/__tests__/CodexProcessTermination.test.ts @@ -0,0 +1,176 @@ +import {mkdtempSync, existsSync, readFileSync, rmSync, writeFileSync} from "node:fs"; +import {tmpdir} from "node:os"; +import path from "node:path"; +import {fileURLToPath} from "node:url"; +import {once} from "node:events"; +import {afterEach, describe, expect, it, vi} from "vitest"; +import {startCodexConnection, terminateCodexConnection, type CodexConnection} from "../CodexJsonRpcConnection"; + +vi.mock("../Logger", () => ({logger: {log: vi.fn(), error: vi.fn()}})); +// Exercise the default launcher branch without launching the installed Codex. +vi.mock("node:module", async importOriginal => { + const original = await importOriginal(); + return {...original, createRequire: (...args: Parameters) => { + const require = original.createRequire(...args); + const resolve = require.resolve; + require.resolve = Object.assign((id: string, options?: Parameters[1]) => + id === "@openai/codex/bin/codex.js" + ? fileURLToPath(new URL("./fixtures/CodexProcessTermination.cjs", import.meta.url)) + : resolve(id, options), {paths: resolve.paths}); + return require; + }}; +}); + +const fixture = fileURLToPath(new URL("./fixtures/CodexProcessTermination.cjs", import.meta.url)); +const fixtures: {directory: string; native: CodexConnection}[] = []; +const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +async function until(check: () => boolean, timeout = 10_000): Promise { + const deadline = Date.now() + timeout; + while (!check()) { + if (Date.now() > deadline) throw new Error("Fixture condition timed out"); + await delay(20); + } +} + +function alive(pid: number): boolean { + try { + process.kill(pid, 0); + // Zombies have exited, though kill(0) still sees them until the OS reaps them. + if (process.platform === "linux") { + const stat = readFileSync(`/proc/${pid}/stat`, "utf8"); + return stat.slice(stat.lastIndexOf(")") + 2, stat.lastIndexOf(")") + 3) !== "Z"; + } + return true; + } catch (error) { + if (["ESRCH", "ENOENT"].includes((error as NodeJS.ErrnoException).code ?? "")) return false; + throw error; + } +} + +async function startFixture(defaultLauncher = false) { + const directory = mkdtempSync(path.join(tmpdir(), "CodexProcessTermination-")); + const native = startCodexConnection(defaultLauncher ? undefined : process.execPath, + {...process.env, APP_SERVER_LOGS: ""}, defaultLauncher ? ["parent", directory] : [fixture, "parent", directory]); + fixtures.push({directory, native}); + await until(() => existsSync(path.join(directory, "child.pid")) && existsSync(path.join(directory, "heartbeat"))); + const parentPid = Number(readFileSync(path.join(directory, "parent.pid"), "utf8")); + const childPid = Number(readFileSync(path.join(directory, "child.pid"), "utf8")); + return {directory, native, parentPid, childPid}; +} + +afterEach(async () => { + for (const {directory, native} of fixtures.splice(0)) { + // Cleanup through a fixture-specific file, never by a potentially stale PID. + writeFileSync(path.join(directory, "cleanup"), "stop"); + await terminateCodexConnection(native).catch(() => {}); + await delay(150); + rmSync(directory, {recursive: true, force: true}); + } +}); + +describe("CodexProcessTermination real owned processes", () => { + for (const mode of ["eof", "signal"]) { + it.skipIf(mode === "signal" && process.platform === "win32")(`preserves provider stdio and exit status through ${mode}`, async () => { + const directory = mkdtempSync(path.join(tmpdir(), "CodexProcessTermination-")); + const native = startCodexConnection(process.execPath, {...process.env, APP_SERVER_LOGS: ""}, + [fixture, "stdio", directory]); + fixtures.push({directory, native}); + let stdout = ""; + let stderr = ""; + native.process.stdout.on("data", data => {stdout += data;}); + native.process.stderr.on("data", data => {stderr += data;}); + await until(() => existsSync(path.join(directory, "ready"))); + const line = '{"method":"fixture/echo","params":{"text":"你好"}}\n'; + native.process.stdin.write(line); + await until(() => stdout === line); + const closed = once(native.process, "close"); + if (mode === "eof") native.process.stdin.end(); + else native.process.kill("SIGTERM"); + await closed; + expect(native.process.exitCode).toBe(mode === "eof" ? 17 : 29); + expect(stderr).toBe(mode === "eof" ? "fixture EOF\n" : "fixture SIGTERM\n"); + if (process.platform !== "win32") await terminateCodexConnection(native); + }, 15_000); + } + + it.skipIf(process.platform !== "win32")("does not mistake a failed Windows shim launch for a verified stopped tree", async () => { + const directory = mkdtempSync(path.join(tmpdir(), "CodexProcessTermination-")); + const native = startCodexConnection(path.join(directory, "missing-provider.exe")); + fixtures.push({directory, native}); + const [error] = await once(native.process, "error"); + expect(error.code).toBe("ENOENT"); + await until(() => native.process.exitCode !== null || native.process.signalCode !== null); + const termination = terminateCodexConnection(native); + await expect(termination).rejects.toThrow("tree state is unknown"); + expect(terminateCodexConnection(native)).toBe(termination); + }); + + it.skipIf(process.platform === "win32")("reports a provider spawn failure on stderr and exits without holding pipes open", async () => { + const directory = mkdtempSync(path.join(tmpdir(), "CodexProcessTermination-")); + const native = startCodexConnection(path.join(directory, "missing-provider")); + fixtures.push({directory, native}); + let stderr = ""; + native.process.stderr.on("data", data => {stderr += data;}); + await once(native.process, "close"); + expect(native.process.exitCode).toBe(1); + expect(stderr).toContain("ENOENT"); + await terminateCodexConnection(native); + }, 15_000); + + it("stops the default Node launcher and its native-shaped child", async () => { + const target = await startFixture(true); + await terminateCodexConnection(target.native); + await until(() => !alive(target.parentPid) && !alive(target.childPid)); + }, 20_000); + + it("stops both an ignoring parent and child, leaves another tree alive, and shares repeated closes", async () => { + const target = await startFixture(); + const unrelated = await startFixture(); + expect(alive(target.parentPid)).toBe(true); + expect(alive(target.childPid)).toBe(true); + if (process.platform !== "win32") { + process.kill(target.parentPid, "SIGTERM"); + process.kill(target.childPid, "SIGTERM"); + await delay(100); + expect(alive(target.parentPid)).toBe(true); + expect(alive(target.childPid)).toBe(true); + } + const first = terminateCodexConnection(target.native); + expect(terminateCodexConnection(target.native)).toBe(first); + await first; + await until(() => !alive(target.parentPid) && !alive(target.childPid)); + expect(alive(unrelated.parentPid)).toBe(true); + expect(alive(unrelated.childPid)).toBe(true); + await expect(terminateCodexConnection(target.native)).resolves.toBeUndefined(); + await terminateCodexConnection(unrelated.native); + }, 25_000); + + it.each(["exit-parent", "signal-parent"])("handles %s before close without killing a stale launcher PID", async mode => { + const target = await startFixture(); + writeFileSync(path.join(target.directory, mode), "stop"); + await until(() => target.native.process.exitCode !== null || target.native.process.signalCode !== null); + if (process.platform === "win32") { + // taskkill cannot safely reconstruct a tree once its launcher has exited. + await expect(terminateCodexConnection(target.native)).rejects.toThrow("tree state is unknown"); + } else { + if (mode === "signal-parent") { + expect(target.native.process.exitCode).toBeNull(); + expect(target.native.process.signalCode).toBe("SIGKILL"); + } else { + expect(target.native.process.exitCode).toBe(23); + } + await terminateCodexConnection(target.native); + await until(() => !alive(target.childPid)); + } + }, 20_000); + + it.skipIf(process.platform === "win32")("cleans descendants even when the supervisor is killed first", async () => { + const target = await startFixture(); + target.native.process.kill("SIGKILL"); + await until(() => target.native.process.signalCode !== null); + await terminateCodexConnection(target.native); + expect(alive(target.parentPid)).toBe(false); + expect(alive(target.childPid)).toBe(false); + }, 20_000); +}); diff --git a/src/__tests__/SessionDiscovery.test.ts b/src/__tests__/SessionDiscovery.test.ts new file mode 100644 index 000000000..1d94faae8 --- /dev/null +++ b/src/__tests__/SessionDiscovery.test.ts @@ -0,0 +1,31 @@ +import {describe, expect, it, vi} from "vitest"; +import {searchSessions, sessionAttachments, sessionAttachmentsParser, sessionSearchParser, type SessionDiscoveryClient} from "../SessionDiscovery"; +import type {Thread} from "../app-server/v2"; + +describe("native session discovery", () => { + it("rejects invalid search ranges and arbitrary native parameters", () => { + for (const p of [{searchTerm: " "}, {searchTerm: "x", limit: 0}, {searchTerm: "x", limit: 101}, {searchTerm: "x", method: "thread/delete"}]) { + expect(sessionSearchParser.safeParse(p).success).toBe(false); + } + }); + it("preserves the native cursor and snippet without leaking runtime config", async () => { + const native = {threadSearch: vi.fn(async () => ({data: [{thread: {id: "s", cwd: "/work", name: "name", updatedAt: 42, modelProvider: "private-config"} as Thread, snippet: "match"}], nextCursor: "opaque"}))}; + expect(await searchSessions(native, {searchTerm: "match", archived: true, limit: 20})).toEqual({version: 1, sessions: [{sessionId: "s", cwd: "/work", title: "name", updatedAt: 42, snippet: "match"}], nextCursor: "opaque"}); + expect(native.threadSearch).toHaveBeenCalledWith({searchTerm: "match", archived: true, limit: 20}); + }); + it("bounds attachment metadata and rejects extra destinations", () => { + const base = {sessionId: "s", action: "add", attachmentType: "pull_request", identityKey: "https://example.test/pr/1"}; + expect(sessionAttachmentsParser.safeParse({...base, payload: {title: "PR"}}).success).toBe(true); + expect(sessionAttachmentsParser.safeParse({...base, payload: "x".repeat(256 * 1024)}).success).toBe(false); + expect(sessionAttachmentsParser.safeParse({...base, payload: {}, threadId: "other"}).success).toBe(false); + }); + it("uses the checked session identity for every attachment operation", async () => { + const add = vi.fn(async () => ({outcome: "created", attachment: {id: "a"}})); + const native = {threadAttachmentAdd: add} as unknown as SessionDiscoveryClient; + const request = {sessionId: "s", action: "add" as const, attachmentType: "pull_request", identityKey: "url", payload: {url: "https://example.test/pr/1"}}; + await expect(sessionAttachments(native, "other", request)).rejects.toThrow("Session mismatch"); + expect(add).not.toHaveBeenCalled(); + await sessionAttachments(native, "s", request); + expect(add).toHaveBeenCalledWith({threadId: "s", attachmentType: "pull_request", identityKey: "url", payload: request.payload}); + }); +}); diff --git a/src/__tests__/SessionFileRevert.test.ts b/src/__tests__/SessionFileRevert.test.ts new file mode 100644 index 000000000..50ec84f86 --- /dev/null +++ b/src/__tests__/SessionFileRevert.test.ts @@ -0,0 +1,175 @@ +import { execFileSync } from "node:child_process"; +import { mkdtemp, mkdir, writeFile, readFile, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import type { Thread, ThreadItem } from "../app-server/v2"; +import { revertSessionFiles } from "../SessionFileRevert"; + +const directories: string[] = []; +afterEach(async () => { + await Promise.all( + directories + .splice(0) + .map((dir) => rm(dir, { recursive: true, force: true })), + ); +}); +async function setup(changes?: unknown[]) { + const cwd = await mkdtemp(path.join(os.tmpdir(), "codex-file-revert-")); + directories.push(cwd); + execFileSync("git", ["init", "-q", cwd], { windowsHide: true }); + await writeFile(path.join(cwd, "changed.txt"), "old\n"); + execFileSync("git", ["add", "."], { cwd, windowsHide: true }); + const index = await readFile(path.join(cwd, ".git/index")); + await writeFile(path.join(cwd, "changed.txt"), "new\n"); + await writeFile(path.join(cwd, "user.txt"), "keep user's work\n"); + const item = { + type: "fileChange", + id: "tool", + status: "completed", + changes: changes ?? [ + { + path: path.join(cwd, "changed.txt"), + kind: { type: "update", move_path: null }, + diff: "@@ -1 +1 @@\n-old\n+new\n", + }, + ], + } as ThreadItem; + const readHistory = async () => ({ + thread: { id: "session", turns: [{ id: "turn", items: [item] }] } as Thread, + }); + return { + cwd, + index, + readHistory, + session: { sessionId: "session", cwd, additionalDirectories: [] }, + request: { sessionId: "session", toolCallId: "tool", dryRun: true }, + }; +} +describe("native file patch revert", () => { + it("previews then reverses the Git text patch while preserving the real index and unrelated edits", async () => { + const f = await setup(); + const preview = await revertSessionFiles( + f.request, + f.session, + f.readHistory, + ); + expect(preview.canRevert).toBe(true); + expect(await readFile(path.join(f.cwd, "changed.txt"), "utf8")).toBe( + "new\n", + ); + const applied = await revertSessionFiles( + { ...f.request, dryRun: false, previewToken: preview.previewToken }, + f.session, + f.readHistory, + ); + expect(applied.reverted).toBe(true); + expect( + (await readFile(path.join(f.cwd, "changed.txt"), "utf8")).replaceAll( + "\r\n", + "\n", + ), + ).toBe("old\n"); + expect(await readFile(path.join(f.cwd, "user.txt"), "utf8")).toBe("keep user's work\n"); + expect(await readFile(path.join(f.cwd, ".git/index"))).toEqual(f.index); + }); + it("refuses a changed file after preview and does not overwrite the user's new content", async () => { + const f = await setup(); + const preview = await revertSessionFiles( + f.request, + f.session, + f.readHistory, + ); + await writeFile(path.join(f.cwd, "changed.txt"), "new\nuser addition\n"); + expect( + await revertSessionFiles( + { ...f.request, dryRun: false, previewToken: preview.previewToken }, + f.session, + f.readHistory, + ), + ).toMatchObject({ reverted: false, reason: "stale_preview" }); + expect(await readFile(path.join(f.cwd, "changed.txt"), "utf8")).toBe( + "new\nuser addition\n", + ); + }); + it("rejects paths outside the session and Git metadata", async () => { + for (const file of ["../outside.txt", ".git/config"]) { + const f = await setup([ + { path: file, kind: { type: "add" }, diff: "x\n" }, + ]); + expect( + await revertSessionFiles(f.request, f.session, f.readHistory), + ).toMatchObject({ canRevert: false, reverted: false }); + } + }); + it("does not touch files without an explicit preview token", async () => { + const f = await setup(); + await expect( + revertSessionFiles( + { ...f.request, dryRun: false }, + f.session, + f.readHistory, + ), + ).rejects.toThrow(); + }); + it("undoes recorded create/delete patches in one batch", async () => { + const f = await setup([ + { path: "created.txt", kind: { type: "add" }, diff: "created\n" }, + { path: "deleted.txt", kind: { type: "delete" }, diff: "deleted\n" }, + ]); + await writeFile(path.join(f.cwd, "created.txt"), "created\n"); + const preview = await revertSessionFiles( + f.request, + f.session, + f.readHistory, + ); + expect(preview.canRevert).toBe(true); + expect( + await revertSessionFiles( + { ...f.request, dryRun: false, previewToken: preview.previewToken }, + f.session, + f.readHistory, + ), + ).toMatchObject({ reverted: true }); + await expect(readFile(path.join(f.cwd, "created.txt"))).rejects.toThrow(); + expect( + (await readFile(path.join(f.cwd, "deleted.txt"), "utf8")).replaceAll( + "\r\n", + "\n", + ), + ).toBe("deleted\n"); + }); + it("refuses a patch whose current content conflicts", async () => { + const f = await setup(); + await writeFile(path.join(f.cwd, "changed.txt"), "different\n"); + expect( + await revertSessionFiles(f.request, f.session, f.readHistory), + ).toMatchObject({ reason: "patch_conflict", canRevert: false }); + }); + it("refuses directory nodes as patch targets", async () => { + const f = await setup([ + { path: "folder", kind: { type: "add" }, diff: "x\n" }, + ]); + await mkdir(path.join(f.cwd, "folder")); + expect( + await revertSessionFiles(f.request, f.session, f.readHistory), + ).toMatchObject({ reason: "unsupported_file_type" }); + }); + it.each([{type:"futureKind"}, null, {}])("rejects an entire mixed batch with an unknown kind %j", async kind => { + const f = await setup([ + {path:"changed.txt",kind:{type:"update",move_path:null},diff:"@@ -1 +1 @@\n-old\n+new\n"}, + {path:"user.txt",kind,diff:"user content"}, + ]); + const result = await revertSessionFiles(f.request, f.session, f.readHistory); + expect(result).toMatchObject({canRevert:false,reverted:false,reason:"unsupported_patch"}); + expect(await readFile(path.join(f.cwd,"changed.txt"),"utf8")).toBe("new\n"); + expect(await readFile(path.join(f.cwd,"user.txt"),"utf8")).toBe("keep user's work\n"); + expect(await readFile(path.join(f.cwd,".git/index"))).toEqual(f.index); + }); + it("rejects a mixed batch containing a missing diff", async () => { + const f=await setup([{path:"changed.txt",kind:{type:"update",move_path:null},diff:"@@ -1 +1 @@\n-old\n+new\n"},{path:"user.txt",kind:{type:"add"}}]); + expect(await revertSessionFiles(f.request,f.session,f.readHistory)).toMatchObject({reverted:false,reason:"unsupported_patch"}); + expect(await readFile(path.join(f.cwd,"changed.txt"),"utf8")).toBe("new\n"); + }); + +}); diff --git a/src/__tests__/SessionQueue.test.ts b/src/__tests__/SessionQueue.test.ts new file mode 100644 index 000000000..6e5e5630a --- /dev/null +++ b/src/__tests__/SessionQueue.test.ts @@ -0,0 +1,196 @@ +import {describe, expect, it, vi} from "vitest"; +import type {Turn} from "../app-server/v2/Turn"; +import { + SESSION_QUEUE_ACTIONS, SESSION_QUEUE_METHOD, parseSessionQueueRequest, + probeSessionQueueSupport, runSessionQueue, sessionQueueCapability, + type NativeQueueInput, type SessionQueueNative, type SessionQueueSupport, +} from "../SessionQueue"; + +const input: NativeQueueInput[] = [ + {type: "text", text: "正文", text_elements: [{byteRange: {start: 0, end: 6}, placeholder: null}]}, + {type: "text", text: " keep whitespace "}, + {type: "image", fileId: "file-id", detail: "original"}, + {type: "image", url: "data:image/png;base64,AA==", detail: null}, + {type: "localImage", path: "C:\\a.png"}, + {type: "audio", url: "data:audio/wav;base64,AA=="}, + {type: "localAudio", path: "C:\\a.wav"}, + {type: "skill", name: "skill", path: "C:\\SKILL.md"}, + {type: "mention", name: "file", path: "C:\\file.txt"}, +]; +const support: SessionQueueSupport = {nativeVersion: "0.160.1", actions: SESSION_QUEUE_ACTIONS}; +const queued = {id: "native-q", input, clientUserMessageId: "client-id", futureNativeField: {keep: true}}; +const turn: Turn = {id: "native-turn", items: [], itemsView: "notLoaded", status: "inProgress", error: null, startedAt: null, completedAt: null, durationMs: null}; + +function fixture() { + const native = { + list: vi.fn().mockResolvedValue({data: [queued], nextCursor: "opaque-next"}), + add: vi.fn().mockResolvedValue({queuedSubmission: queued}), + update: vi.fn().mockResolvedValue({queuedSubmission: queued}), + delete: vi.fn().mockResolvedValue({deleted: false}), + reorder: vi.fn().mockResolvedValue({}), + start: vi.fn().mockResolvedValue({turn}), + }; + const dependencies = {sessionId: "session", support, native}; + return {native, dependencies, run: (request: unknown) => runSessionQueue(request, dependencies)}; +} + +describe("session queue capability", () => { + it.each(["0.154.0", "0.159.3", "0.160.0", "0.160.1-preview.1", "codex 0.160.1", "2.1.1-preview", "", null, undefined])( + "does not probe or advertise unknown/old/unverified native %s", async version => { + const probe = vi.fn(async () => true); + const result = await probeSessionQueueSupport(version, probe); + expect(probe).not.toHaveBeenCalled(); + expect(result.actions).toEqual([]); + expect(sessionQueueCapability(result)).toBeUndefined(); + }, + ); + it("advertises only each action positively confirmed on the running native", async () => { + const result = await probeSessionQueueSupport("0.160.1", async action => { + if (action === "start") throw new Error("probe transport failed"); + return action === "list"; + }); + expect(sessionQueueCapability(result)).toEqual({version: 1, method: SESSION_QUEUE_METHOD, actions: ["list"]}); + expect(Object.isFrozen(result.actions)).toBe(true); + }); + it.each(["0.160.1", "0.161.0", "1.0.0"])("still requires positive evidence for %s", async version => { + const result = await probeSessionQueueSupport(version, async () => false); + expect(sessionQueueCapability(result)).toBeUndefined(); + }); + it("rejects an old native support record even if its actions were supplied", async () => { + const f = fixture(); + f.dependencies.support = {nativeVersion: "0.154.0", actions: SESSION_QUEUE_ACTIONS}; + await expect(f.run({sessionId: "session", action: "start"})).resolves.toEqual({status: "unsupported"}); + expect(f.native.start).not.toHaveBeenCalled(); + }); +}); + +describe("session queue native boundary", () => { + it("keeps native multimodal input, omission, IDs, and unknown RESULT fields", async () => { + const f = fixture(); + const result = {queuedSubmission: queued, nativeExtra: {revision: "native-owned"}}; + f.native.add.mockResolvedValue(result); + await expect(f.run({sessionId: "session", action: "add", input, clientUserMessageId: "client-id"})) + .resolves.toEqual({status: "ok", result}); + expect(f.native.add).toHaveBeenCalledExactlyOnceWith({threadId: "session", input, clientUserMessageId: "client-id"}); + expect(f.native.add.mock.calls[0]![0].input[1]).not.toHaveProperty("text_elements"); + expect(result.queuedSubmission.id).toBe("native-q"); + }); + it("preserves list pagination, nulls, and the native result object", async () => { + const f = fixture(); + const result = {data: [queued], nextCursor: "opaque-next", total: 200}; + f.native.list.mockResolvedValue(result); + const response = await f.run({sessionId: "session", action: "list", cursor: "opaque-in", limit: 0}); + expect(response.status === "ok" && response.result).toBe(result); + expect(f.native.list).toHaveBeenLastCalledWith({threadId: "session", cursor: "opaque-in", limit: 0}); + await f.run({sessionId: "session", action: "list", cursor: null, limit: null}); + expect(f.native.list).toHaveBeenLastCalledWith({threadId: "session", cursor: null, limit: null}); + await f.run({sessionId: "session", action: "list"}); + expect(f.native.list).toHaveBeenLastCalledWith({threadId: "session"}); + }); + it("updates only the named native submission without replacing its client ID", async () => { + const f = fixture(); + await expect(f.run({sessionId: "session", action: "update", queuedSubmissionId: "q", input})) + .resolves.toEqual({status: "ok", result: {queuedSubmission: queued}}); + expect(f.native.update).toHaveBeenCalledExactlyOnceWith({threadId: "session", queuedSubmissionId: "q", input}); + }); + it("keeps delete=false and does not invent successful removal", async () => { + const f = fixture(); + await expect(f.run({sessionId: "session", action: "delete", queuedSubmissionId: "gone"})) + .resolves.toEqual({status: "ok", result: {deleted: false}}); + expect(f.native.delete).toHaveBeenCalledExactlyOnceWith({threadId: "session", queuedSubmissionId: "gone"}); + }); + it("delegates native ordering and membership validation without local reordering", async () => { + const f = fixture(); + const ids = ["third", "first"]; + await expect(f.run({sessionId: "session", action: "reorder", queuedSubmissionIds: ids})) + .resolves.toEqual({status: "ok", result: {}}); + expect(f.native.reorder).toHaveBeenCalledExactlyOnceWith({threadId: "session", queuedSubmissionIds: ids}); + expect(f.native.list).not.toHaveBeenCalled(); + }); + it.each([{}, {queuedSubmissionId: null}, {queuedSubmissionId: "q"}])("only fake-starts once with native selector %j", async selector => { + const f = fixture(); + await expect(f.run({sessionId: "session", action: "start", ...selector})) + .resolves.toEqual({status: "ok", result: {turn}}); + expect(f.native.start).toHaveBeenCalledExactlyOnceWith({threadId: "session", ...selector}); + expect(f.native.delete).not.toHaveBeenCalled(); + }); + it.each(SESSION_QUEUE_ACTIONS)("never accesses another session for %s", async action => { + const f = fixture(); + const requests = { + list: {}, add: {input, clientUserMessageId: "id"}, update: {input, queuedSubmissionId: "id"}, + delete: {queuedSubmissionId: "id"}, reorder: {queuedSubmissionIds: []}, start: {}, + }; + await expect(f.run({sessionId: "other", action, ...requests[action]})).rejects.toThrow("bound session"); + for (const callback of Object.values(f.native)) expect(callback).not.toHaveBeenCalled(); + }); + it("rejects unproven start even when list was positively probed", async () => { + const f = fixture(); + f.dependencies.support = await probeSessionQueueSupport("0.160.1", async action => action === "list"); + await expect(f.run({sessionId: "session", action: "start"})).resolves.toEqual({status: "unsupported"}); + expect(f.native.start).not.toHaveBeenCalled(); + }); + it("reports a definitive method-not-found without retry or fallback", async () => { + const f = fixture(); + f.native.start.mockRejectedValue({code: -32601, message: "Method not found"}); + await expect(f.run({sessionId: "session", action: "start"})).resolves.toEqual({status: "unsupported"}); + expect(f.native.start).toHaveBeenCalledTimes(1); + }); + it.each([ + {code: -32602, message: "thread not found"}, + {code: -32603, message: "storage failed"}, + new Error("response lost after native commit"), + ])("propagates failures without replaying an uncertain mutation", async error => { + const f = fixture(); + f.native.add.mockRejectedValue(error); + await expect(f.run({sessionId: "session", action: "add", input, clientUserMessageId: "client-id"})).rejects.toBe(error); + expect(f.native.add).toHaveBeenCalledTimes(1); + expect(f.native.list).not.toHaveBeenCalled(); + }); +}); + +describe("session queue input validation", () => { + it.each([ + null, [], "list", {}, + {sessionId: "session", action: "thread/delete"}, + {sessionId: "session", action: "list", threadId: "other"}, + {sessionId: "session", action: "list", method: "thread/delete"}, + {sessionId: "session", action: "list", params: {threadId: "other"}}, + {sessionId: "session", action: "list", limit: -1}, + {sessionId: "session", action: "list", limit: 1.5}, + {sessionId: "session", action: "list", limit: 0x1_0000_0000}, + {sessionId: "session", action: "list", cursor: 1}, + {sessionId: " ", action: "list"}, + {sessionId: "session\n", action: "list"}, + {sessionId: "session", action: "add", input}, + {sessionId: "session", action: "add", input, clientUserMessageId: "id", revision: 1}, + {sessionId: "session", action: "start", input}, + {sessionId: "session", action: "start", queuedSubmissionId: ""}, + {sessionId: "session", action: "reorder", queuedSubmissionIds: [null]}, + {sessionId: "session", action: "update", input, queuedSubmissionId: "q", clientUserMessageId: "replacement"}, + ])("rejects malformed/extra fields before any native call: %j", async request => { + const f = fixture(); + await expect(f.run(request)).rejects.toThrow("Invalid session queue request"); + for (const callback of Object.values(f.native)) expect(callback).not.toHaveBeenCalled(); + }); + it.each([ + {type: "text", text: 42}, + {type: "text", text: "x", text_elements: [{byteRange: {start: -1, end: 1}}]}, + {type: "text", text: "x", text_elements: [{byteRange: {start: 0, end: 1}, placeholder: 7}]}, + {type: "image", url: "data:x", fileId: "ambiguous"}, + {type: "image", url: "data:x", detail: "unlimited"}, + {type: "image", fileId: "x", threadId: "other"}, + {type: "toolOutput", text: "inject"}, + {type: "localImage", path: 42}, + ])("rejects unknown/malformed native input: %j", block => { + expect(() => parseSessionQueueRequest({sessionId: "session", action: "add", input: [block], clientUserMessageId: "id"})) + .toThrow("Invalid session queue request"); + }); + it("does not echo rejected prompt/URL contents in errors", () => { + try { + parseSessionQueueRequest({sessionId: "session", action: "add", input: [{type: "unknown", secret: "sensitive"}], clientUserMessageId: "id"}); + expect.fail("should reject"); + } catch (error) { + expect(String(error)).not.toContain("sensitive"); + } + }); +}); diff --git a/src/__tests__/SessionRuntime.test.ts b/src/__tests__/SessionRuntime.test.ts new file mode 100644 index 000000000..ddb78c88e --- /dev/null +++ b/src/__tests__/SessionRuntime.test.ts @@ -0,0 +1,51 @@ +import {describe, expect, it, vi} from "vitest"; +import {controlRuntime, readRuntime, runtimeControlParser, runtimeReadParser, type RuntimeSession} from "../SessionRuntime"; + +const session: RuntimeSession = { + sessionId: "s", cwd: "/workspace", additionalDirectories: ["/extra"], currentModelId: "m", + lastTokenUsage: null, totalTokenUsage: null, modelContextWindow: null, rateLimits: null, +}; +const client = () => ({ + listSkills: vi.fn(async () => ({data: []})), + listMcpServers: vi.fn(async () => ({data: [], nextCursor: null})), + installedPlugins: vi.fn(async () => ({marketplaces: [], marketplaceLoadErrors: []})), + reconcilePlugins: vi.fn(async () => ({changedPlugins: [], failedRemotePluginIds: [], failedMaterializationRemotePluginIds: []})), + reloadMcpServers: vi.fn(async () => {}), +}); +describe("runtime extension", () => { + it("rejects arbitrary methods, extra native parameters and missing identity", () => { + for (const input of [{sessionId: "s", resource: "config"}, {resource: "mcp"}, {sessionId: "s", resource: "mcp", threadId: "other"}]) { + expect(runtimeReadParser.safeParse(input).success).toBe(false); + } + expect(runtimeControlParser.safeParse({sessionId: "s", action: "delete"}).success).toBe(false); + }); + it("preserves unavailable context measurements as null", async () => { + const result = await readRuntime(client(), session, {sessionId: "s", resource: "context"}, new AbortController().signal); + expect(result).toMatchObject({data: {totalTokens: null, maxTokens: null, categories: null}}); + }); + it("refreshes skills for the actual session roots", async () => { + const native = client(); + await controlRuntime(native, session, {sessionId: "s", action: "reloadSkills"}); + expect(native.listSkills).toHaveBeenCalledWith({cwds: ["/workspace", "/extra"], forceReload: true}); + }); + it("does not claim MCP readiness from a reload acknowledgement", async () => { + const native = client(); + expect(await controlRuntime(native, session, {sessionId: "s", action: "reconnectMcp"})).toMatchObject({data: {scope: "provider", connectionsReady: false}}); + expect(native.reloadMcpServers).toHaveBeenCalledOnce(); + }); + it("rejects mismatched sessions before the native call", async () => { + const native = client(); + await expect(controlRuntime(native, session, {sessionId: "other", action: "reloadSkills"})).rejects.toThrow("Session mismatch"); + expect(native.listSkills).not.toHaveBeenCalled(); + }); + it("does not start a cancelled status read", async () => { + const native = client(); const abort = new AbortController(); abort.abort(); + expect(await readRuntime(native, session, {sessionId: "s", resource: "commands"}, abort.signal)).toMatchObject({reason: "cancelled"}); + expect(native.listSkills).not.toHaveBeenCalled(); + }); + it("does not publish the old provider's answer after replacement", async () => { + const native = client(); let current = true; + native.listSkills.mockImplementation(async () => {current = false; return {data: []};}); + expect(await readRuntime(native, session, {sessionId: "s", resource: "commands"}, new AbortController().signal, () => current)).toMatchObject({reason: "stale"}); + }); +}); diff --git a/src/__tests__/fixtures/CodexProcessTermination.cjs b/src/__tests__/fixtures/CodexProcessTermination.cjs new file mode 100644 index 000000000..15d619082 --- /dev/null +++ b/src/__tests__/fixtures/CodexProcessTermination.cjs @@ -0,0 +1,48 @@ +// Only synthetic processes: no Codex configuration, credentials, or model traffic. +const {spawn} = require("node:child_process"); +const {writeFileSync, existsSync} = require("node:fs"); + +let [role, directory, mode] = process.argv.slice(2); +if (role === "app-server") { + role = "stdio"; + directory = process.env.CODEX_PROCESS_TERMINATION_FIXTURE_DIR; +} +const write = (name, value) => writeFileSync(`${directory}/${name}`, String(value)); +if (role === "stdio") { + process.stdin.resume(); + process.stdin.on("data", data => process.stdout.write(data)); + process.stdin.on("end", () => { + process.stderr.write("fixture EOF\n", () => process.exit(17)); + }); + process.on("SIGTERM", () => { + process.stderr.write("fixture SIGTERM\n", () => process.exit(29)); + }); + write("ready", process.pid); + setTimeout(() => process.exit(99), 30_000).unref(); +} else if (role === "child") { + process.on("SIGTERM", () => {}); + write("child.pid", process.pid); + // A safety lease bounds the lifetime even if a test crashes before cleanup. + const lease = setTimeout(() => process.exit(0), 30_000); + const heartbeat = setInterval(() => { + if (existsSync(`${directory}/cleanup`)) { + clearInterval(heartbeat); + clearTimeout(lease); + process.exit(0); + } + write("heartbeat", Date.now()); + }, 25); +} else { + process.on("SIGTERM", () => {}); + const childArgs = typeof Bun !== "undefined" && Bun.main.startsWith("/$bunfs/") + ? ["child", directory, mode] : [__filename, "child", directory, mode]; + const child = spawn(process.execPath, childArgs, {stdio: "inherit"}); + child.on("error", () => process.exit(1)); + write("parent.pid", process.pid); + setTimeout(() => process.exit(0), 30_000); + setInterval(() => { + if (existsSync(`${directory}/exit-parent`)) process.exit(23); + if (existsSync(`${directory}/signal-parent`)) process.kill(process.pid, "SIGKILL"); + if (existsSync(`${directory}/cleanup`)) process.exit(0); + }, 25); +} diff --git a/src/__tests__/fixtures/CodexProcessTermination.compiled.ts b/src/__tests__/fixtures/CodexProcessTermination.compiled.ts new file mode 100644 index 000000000..ba63ede7e --- /dev/null +++ b/src/__tests__/fixtures/CodexProcessTermination.compiled.ts @@ -0,0 +1,90 @@ +// Compile this entrypoint with `bun build --compile` and run with: +// +// Keep build output outside dist. Linux tests need an init that reaps orphans. +import assert from "node:assert/strict"; +import {mkdtempSync, readFileSync, existsSync, writeFileSync, rmSync, mkdirSync} from "node:fs"; +import {tmpdir} from "node:os"; +import path from "node:path"; +import {once} from "node:events"; +import {runInternalProcessHelper} from "../../CodexProcessHelpers"; +await runInternalProcessHelper(); +const {startCodexConnection, terminateCodexConnection} = await import("../../CodexJsonRpcConnection"); + +const [node, fixture] = process.argv.slice(2); +assert(node && fixture, "Pass Node and fixture paths"); +const isBun = Boolean(process.versions["bun"]); +if (isBun) assert.notEqual(process.execPath, node); +const directory = mkdtempSync(path.join(tmpdir(), "CodexProcessTermination-compiled-")); +const env = {...process.env, PATH: directory, APP_SERVER_LOGS: ""}; +const providerArgs = (role: string, dir: string) => fixture === "native" ? [role, dir] : [fixture, role, dir]; +const until = async (check: () => boolean) => { + const deadline = Date.now() + 10_000; + while (!check()) { + assert(Date.now() < deadline, "Fixture wait timed out"); + await new Promise(resolve => setTimeout(resolve, 20)); + } +}; +try { + for (const mode of ["kill", "parent-first"]) { + const caseDir = path.join(directory, mode); + mkdirSync(caseDir); + const native = startCodexConnection(node, env, providerArgs("parent", caseDir)); + native.process.stderr.on("data", data => process.stderr.write(data)); + try { + await until(() => existsSync(path.join(caseDir, "heartbeat"))); + const pids = ["parent.pid", "child.pid"].map(name => Number(readFileSync(path.join(caseDir, name), "utf8"))); + if (mode === "parent-first") { + const exited = once(native.process, "exit"); + writeFileSync(path.join(caseDir, "exit-parent"), "exit"); + await exited; + assert.equal(native.process.exitCode, 23); + } + await terminateCodexConnection(native); + for (const pid of pids) assert.throws(() => process.kill(pid, 0), {code: "ESRCH"}); + await terminateCodexConnection(native); + console.log(`PASS compiled ${mode}: parent and child gone`); + } finally { + writeFileSync(path.join(caseDir, "cleanup"), "stop"); + await terminateCodexConnection(native).catch(() => {}); + } + } + const native = startCodexConnection(node, env, providerArgs("stdio", directory)); + let stdout = ""; + let stderr = ""; + native.process.stdout.on("data", data => {stdout += data;}); + native.process.stderr.on("data", data => {stderr += data;}); + await until(() => existsSync(path.join(directory, "ready"))); + const line = '{"method":"fixture/echo","params":{"text":"你好"}}\n'; + native.process.stdin.write(line); + await until(() => stdout === line); + const closed = once(native.process, "close"); + native.process.stdin.end(); + await closed; + assert.equal(native.process.exitCode, 17); + assert.equal(stderr, "fixture EOF\n"); + await terminateCodexConnection(native); + console.log("PASS compiled EOF: exact stdout/stderr and exit code 17"); + + const stalledDir = path.join(directory, "stalled"); + mkdirSync(stalledDir); + const stalled = startCodexConnection(node, env, providerArgs("parent", stalledDir)); + await until(() => existsSync(path.join(stalledDir, "heartbeat"))); + const group = stalled.process.pid!; + process.kill(-group, "SIGSTOP"); // Hold this live owned group so it cannot service the control pipe. + try { + await assert.rejects(terminateCodexConnection(stalled), /timed out; tree state is unknown/); + assert.equal(stalled.process.exitCode, null); + assert.equal(stalled.process.signalCode, null); + } finally { + process.kill(-group, "SIGCONT"); // The stopped group still pins its identity. + } + await until(() => { + try {process.kill(-group, 0); return false;} + catch (error) {if ((error as NodeJS.ErrnoException).code === "ESRCH") return true; throw error;} + }); + console.log("PASS compiled timeout: stopped group rejected after deadline, then reaped after resume"); + + +} finally { + rmSync(directory, {recursive: true, force: true}); +} diff --git a/src/__tests__/fixtures/CodexProcessTermination.entrypoint.cjs b/src/__tests__/fixtures/CodexProcessTermination.entrypoint.cjs new file mode 100644 index 000000000..a2904a998 --- /dev/null +++ b/src/__tests__/fixtures/CodexProcessTermination.entrypoint.cjs @@ -0,0 +1,43 @@ +// Run with node . Both children use +// a PATH without Node; this harness itself does not supply an interpreter to them. +const {spawn, spawnSync} = require("node:child_process"); +const {mkdtempSync, existsSync, rmSync} = require("node:fs"); +const {tmpdir} = require("node:os"); +const path = require("node:path"); +const assert = require("node:assert/strict"); +const {once} = require("node:events"); + +(async () => { + const [acp, fixture] = process.argv.slice(2); + const dir = mkdtempSync(path.join(tmpdir(), "CodexProcessTermination-entry-")); + try { + const env = {...process.env, PATH: dir, CODEX_PATH: fixture, + APP_SERVER_LOGS: "", CODEX_CONFIG: "{}", DEFAULT_AUTH_REQUEST: "", + CODEX_PROCESS_TERMINATION_FIXTURE_DIR: dir}; + const provider = spawn(acp, [], {env, stdio: "pipe"}); + let stderr = ""; + provider.stderr.on("data", data => {stderr += data;}); + const exited = once(provider, "exit"); + const deadline = Date.now() + 10_000; + while (!existsSync(path.join(dir, "ready"))) { + assert(Date.now() < deadline, `ACP did not launch native fixture: ${stderr}`); + await new Promise(resolve => setTimeout(resolve, 20)); + } + provider.stdin.end(); + const [code] = await exited; + assert.equal(code, 0, stderr); + console.log("PASS compiled ACP entrypoint: CODEX_PATH native launch and EOF, no Node on PATH"); + for (const role of ["supervisor", "guardian", "unknown"]) { + const result = spawnSync(acp, [`--internal-owned-process-${role}`], { + env: {...env, CODEX_CONFIG: "invalid JSON", APP_SERVER_LOGS: path.join(dir, "forbidden-logs")}, + encoding: "utf8", timeout: 5000, + }); + assert.equal(result.status, 1); + assert(!existsSync(path.join(dir, "forbidden-logs"))); + assert(!result.stderr.includes("JSON")); + } + console.log("PASS compiled ACP helper rejection: no private fd, no config/log initialization"); + } finally { + rmSync(dir, {recursive: true, force: true}); + } +})().catch(error => {console.error(error); process.exitCode = 1;}); diff --git a/src/__tests__/scenarios/baseline.ts b/src/__tests__/scenarios/baseline.ts index a7fe89b96..0a69b24db 100644 --- a/src/__tests__/scenarios/baseline.ts +++ b/src/__tests__/scenarios/baseline.ts @@ -1,3 +1,7 @@ +import {fileRevertCapability} from "../../SessionFileRevert"; +import {runtimeCapability} from "../../SessionRuntime"; +import {archiveCapability} from "../../SessionArchive"; +import {sessionDiscoveryCapability} from "../../SessionDiscovery"; import {canonical, type RecordedMessage} from "./scenario-harness"; /** @@ -141,6 +145,16 @@ const MCP_COMMAND = { export function withFeatureChanges(messages: RecordedMessage[]): RecordedMessage[] { const baselineMcp = JSON.stringify(canonical(BASELINE_MCP_COMMAND)); return messages.map(message => { + if (message.direction === "response" && message.method === "initialize") { + const params = structuredClone(message.params) as Json; + const meta = params["_meta"] as Json; + meta["runtime"] = runtimeCapability(); + meta["archive"] = archiveCapability(); + meta["fileRevert"] = fileRevertCapability(); + meta["discovery"] = sessionDiscoveryCapability(); + (meta["steering"] as Json)["idleBehavior"] = ["promptRequired"]; + return {...message, params}; + } const update = sessionUpdate(message); if (update === undefined || update["sessionUpdate"] !== "available_commands_update") return message; const commands = (update["availableCommands"] as unknown[]).map(command => diff --git a/src/__tests__/scenarios/data/air/agent-message-and-reasoning.jsonl b/src/__tests__/scenarios/data/air/agent-message-and-reasoning.jsonl index 111ed4b63..67092c0fe 100644 --- a/src/__tests__/scenarios/data/air/agent-message-and-reasoning.jsonl +++ b/src/__tests__/scenarios/data/air/agent-message-and-reasoning.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/background-terminal.jsonl b/src/__tests__/scenarios/data/air/background-terminal.jsonl index e5b28624c..8986d133b 100644 --- a/src/__tests__/scenarios/data/air/background-terminal.jsonl +++ b/src/__tests__/scenarios/data/air/background-terminal.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/collab-agent.jsonl b/src/__tests__/scenarios/data/air/collab-agent.jsonl index cb95c1215..b506fea43 100644 --- a/src/__tests__/scenarios/data/air/collab-agent.jsonl +++ b/src/__tests__/scenarios/data/air/collab-agent.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/collab-controls.jsonl b/src/__tests__/scenarios/data/air/collab-controls.jsonl index a970ca093..d7f789cbc 100644 --- a/src/__tests__/scenarios/data/air/collab-controls.jsonl +++ b/src/__tests__/scenarios/data/air/collab-controls.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/command-approval.jsonl b/src/__tests__/scenarios/data/air/command-approval.jsonl index 6fd1e68de..715411f9f 100644 --- a/src/__tests__/scenarios/data/air/command-approval.jsonl +++ b/src/__tests__/scenarios/data/air/command-approval.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/command-failed.jsonl b/src/__tests__/scenarios/data/air/command-failed.jsonl index ef99b4c65..2b976ca80 100644 --- a/src/__tests__/scenarios/data/air/command-failed.jsonl +++ b/src/__tests__/scenarios/data/air/command-failed.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/command-output-stdin.jsonl b/src/__tests__/scenarios/data/air/command-output-stdin.jsonl index 8a8aa2fac..44379acf5 100644 --- a/src/__tests__/scenarios/data/air/command-output-stdin.jsonl +++ b/src/__tests__/scenarios/data/air/command-output-stdin.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/command-without-streamed-output.jsonl b/src/__tests__/scenarios/data/air/command-without-streamed-output.jsonl index cdd05b3c5..5942fc210 100644 --- a/src/__tests__/scenarios/data/air/command-without-streamed-output.jsonl +++ b/src/__tests__/scenarios/data/air/command-without-streamed-output.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/context-compaction.jsonl b/src/__tests__/scenarios/data/air/context-compaction.jsonl index a87cf8139..9499ff51e 100644 --- a/src/__tests__/scenarios/data/air/context-compaction.jsonl +++ b/src/__tests__/scenarios/data/air/context-compaction.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/dynamic-tool.jsonl b/src/__tests__/scenarios/data/air/dynamic-tool.jsonl index e8c2f584f..14167c453 100644 --- a/src/__tests__/scenarios/data/air/dynamic-tool.jsonl +++ b/src/__tests__/scenarios/data/air/dynamic-tool.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/file-change-approval.jsonl b/src/__tests__/scenarios/data/air/file-change-approval.jsonl index 54c749c39..fca2fdb21 100644 --- a/src/__tests__/scenarios/data/air/file-change-approval.jsonl +++ b/src/__tests__/scenarios/data/air/file-change-approval.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/file-changes.jsonl b/src/__tests__/scenarios/data/air/file-changes.jsonl index c563ef108..f5e7b4f83 100644 --- a/src/__tests__/scenarios/data/air/file-changes.jsonl +++ b/src/__tests__/scenarios/data/air/file-changes.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/fuzzy-file-search.jsonl b/src/__tests__/scenarios/data/air/fuzzy-file-search.jsonl index f2cc4dfcd..eef4ce80b 100644 --- a/src/__tests__/scenarios/data/air/fuzzy-file-search.jsonl +++ b/src/__tests__/scenarios/data/air/fuzzy-file-search.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/goal-update.jsonl b/src/__tests__/scenarios/data/air/goal-update.jsonl index 1b809b8ad..8e02886a4 100644 --- a/src/__tests__/scenarios/data/air/goal-update.jsonl +++ b/src/__tests__/scenarios/data/air/goal-update.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/guardian-review.jsonl b/src/__tests__/scenarios/data/air/guardian-review.jsonl index 5e346387e..37ecf7e92 100644 --- a/src/__tests__/scenarios/data/air/guardian-review.jsonl +++ b/src/__tests__/scenarios/data/air/guardian-review.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/history-replay.jsonl b/src/__tests__/scenarios/data/air/history-replay.jsonl index d1e6f13a3..a75be75e3 100644 --- a/src/__tests__/scenarios/data/air/history-replay.jsonl +++ b/src/__tests__/scenarios/data/air/history-replay.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/load","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/image-generation.jsonl b/src/__tests__/scenarios/data/air/image-generation.jsonl index 96afe2d86..b2e206d8a 100644 --- a/src/__tests__/scenarios/data/air/image-generation.jsonl +++ b/src/__tests__/scenarios/data/air/image-generation.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/image-view.jsonl b/src/__tests__/scenarios/data/air/image-view.jsonl index 69465e3ca..9a644c604 100644 --- a/src/__tests__/scenarios/data/air/image-view.jsonl +++ b/src/__tests__/scenarios/data/air/image-view.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/late-subagent-update.jsonl b/src/__tests__/scenarios/data/air/late-subagent-update.jsonl index 263648cc1..6a370e78a 100644 --- a/src/__tests__/scenarios/data/air/late-subagent-update.jsonl +++ b/src/__tests__/scenarios/data/air/late-subagent-update.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/mcp-elicitation.jsonl b/src/__tests__/scenarios/data/air/mcp-elicitation.jsonl index 41d5b7fe4..f3bd64604 100644 --- a/src/__tests__/scenarios/data/air/mcp-elicitation.jsonl +++ b/src/__tests__/scenarios/data/air/mcp-elicitation.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/mcp-startup-failure.jsonl b/src/__tests__/scenarios/data/air/mcp-startup-failure.jsonl index 9560eb4d6..b9cc913bb 100644 --- a/src/__tests__/scenarios/data/air/mcp-startup-failure.jsonl +++ b/src/__tests__/scenarios/data/air/mcp-startup-failure.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"content":[{"content":{"text":"[codex-acp forwarded startup error] MCP server `db` failed to start: boom","type":"text"},"type":"content"}],"kind":"other","sessionUpdate":"tool_call","status":"failed","title":"mcp__db__startup","toolCallId":"mcp_startup.db."}}} diff --git a/src/__tests__/scenarios/data/air/mcp-tool-approval.jsonl b/src/__tests__/scenarios/data/air/mcp-tool-approval.jsonl index fb979992d..80dce2729 100644 --- a/src/__tests__/scenarios/data/air/mcp-tool-approval.jsonl +++ b/src/__tests__/scenarios/data/air/mcp-tool-approval.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/mcp-tool.jsonl b/src/__tests__/scenarios/data/air/mcp-tool.jsonl index 0690b2180..4a133826f 100644 --- a/src/__tests__/scenarios/data/air/mcp-tool.jsonl +++ b/src/__tests__/scenarios/data/air/mcp-tool.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/mcp-url-elicitation.jsonl b/src/__tests__/scenarios/data/air/mcp-url-elicitation.jsonl index a2cff77c8..c0e30ed53 100644 --- a/src/__tests__/scenarios/data/air/mcp-url-elicitation.jsonl +++ b/src/__tests__/scenarios/data/air/mcp-url-elicitation.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/native-subagent-session.jsonl b/src/__tests__/scenarios/data/air/native-subagent-session.jsonl index 96a3405e0..d6360e99a 100644 --- a/src/__tests__/scenarios/data/air/native-subagent-session.jsonl +++ b/src/__tests__/scenarios/data/air/native-subagent-session.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/nested-subagent-session.jsonl b/src/__tests__/scenarios/data/air/nested-subagent-session.jsonl index 3df8ccddc..0f9860399 100644 --- a/src/__tests__/scenarios/data/air/nested-subagent-session.jsonl +++ b/src/__tests__/scenarios/data/air/nested-subagent-session.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/network-and-sandbox-approval.jsonl b/src/__tests__/scenarios/data/air/network-and-sandbox-approval.jsonl index 9b85476ec..379fd626d 100644 --- a/src/__tests__/scenarios/data/air/network-and-sandbox-approval.jsonl +++ b/src/__tests__/scenarios/data/air/network-and-sandbox-approval.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/plan-deltas-and-turn-plan.jsonl b/src/__tests__/scenarios/data/air/plan-deltas-and-turn-plan.jsonl index 7a2a314f3..7a1acd0b2 100644 --- a/src/__tests__/scenarios/data/air/plan-deltas-and-turn-plan.jsonl +++ b/src/__tests__/scenarios/data/air/plan-deltas-and-turn-plan.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/plan-review-permission.jsonl b/src/__tests__/scenarios/data/air/plan-review-permission.jsonl index e0abf6e00..2c2b2812e 100644 --- a/src/__tests__/scenarios/data/air/plan-review-permission.jsonl +++ b/src/__tests__/scenarios/data/air/plan-review-permission.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"plan","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/plan-stream.jsonl b/src/__tests__/scenarios/data/air/plan-stream.jsonl index 12e98fbfc..bdd6b2798 100644 --- a/src/__tests__/scenarios/data/air/plan-stream.jsonl +++ b/src/__tests__/scenarios/data/air/plan-stream.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/read-search-list.jsonl b/src/__tests__/scenarios/data/air/read-search-list.jsonl index 9785dd9bd..81d5d8e1e 100644 --- a/src/__tests__/scenarios/data/air/read-search-list.jsonl +++ b/src/__tests__/scenarios/data/air/read-search-list.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/subagent-activity.jsonl b/src/__tests__/scenarios/data/air/subagent-activity.jsonl index f48d175f8..3280160b7 100644 --- a/src/__tests__/scenarios/data/air/subagent-activity.jsonl +++ b/src/__tests__/scenarios/data/air/subagent-activity.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/web-search.jsonl b/src/__tests__/scenarios/data/air/web-search.jsonl index e7154a72f..f30c28549 100644 --- a/src/__tests__/scenarios/data/air/web-search.jsonl +++ b/src/__tests__/scenarios/data/air/web-search.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/scenario-harness.ts b/src/__tests__/scenarios/scenario-harness.ts index 498ef1fc2..29627bd90 100644 --- a/src/__tests__/scenarios/scenario-harness.ts +++ b/src/__tests__/scenarios/scenario-harness.ts @@ -97,7 +97,14 @@ export async function runScenario( } const messages = await runInWorkspace(replaceText(scenario, WS, workspace), profile, workspace); // A Git patch names the paths without the leading slash. - return replaceText(replaceText(messages, workspace, WS), workspace.slice(1), WS.slice(1)); + // Native locations use platform separators; Git patch headers always use '/'. + const portableWorkspace = workspace.replaceAll("\\", "/"); + const nativePaths = replaceText(replaceText(messages, workspace, WS), portableWorkspace, WS); + const local = portableWorkspace.startsWith("/") + ? replaceText(nativePaths, portableWorkspace.slice(1), WS.slice(1)) + : nativePaths; + return JSON.parse(JSON.stringify(local, (_key, value) => typeof value === "string" + ? value.replaceAll("/workspace\\", "/workspace/").replaceAll("/workspace/src\\", "/workspace/src/").replaceAll("a//workspace/", "a/workspace/").replaceAll("b//workspace/", "b/workspace/").replaceAll("from /workspace/", "from workspace/").replaceAll("to /workspace/", "to workspace/") : value)); } finally { fs.rmSync(workspace, {recursive: true, force: true}); } diff --git a/src/index.ts b/src/index.ts index ff5179d9f..9fc4c6c3c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,26 +1,37 @@ #!/usr/bin/env node - -import * as acp from "@agentclientprotocol/sdk"; -import {z} from "zod"; -import {startCodexConnection} from "./CodexJsonRpcConnection"; -import {CodexAcpServer, type CodexProcessState} from "./CodexAcpServer"; -import {createJsonStream} from "./StdUtils"; -import {isCodexAuthRequest} from "./CodexAuthMethod"; -import {CodexAcpClient} from "./CodexAcpClient"; -import {CodexAppServerClient} from "./CodexAppServerClient"; -import packageJson from "../package.json"; -import {logger} from "./Logger"; -import {runLoginCommand} from "./login"; -import {runCodexCli} from "./CodexCli"; -import {prepareCodexHookConfig} from "./CodexHookConfig"; -import {CODEX_HOOKS_LIST_METHOD, CODEX_HOOKS_TRUST_METHOD} from "./CodexHookTrust"; -import { +import {runInternalProcessHelper} from "./CodexProcessHelpers"; +import type * as AcpTypes from "@agentclientprotocol/sdk"; +import type {CodexProcessState, CodexAcpServer as CodexAcpServerType} from "./CodexAcpServer"; + +await runInternalProcessHelper(); + +const {SESSION_FILE_REVERT_METHOD, sessionFileRevertParser} = await import("./SessionFileRevert"); +const {SESSION_QUEUE_METHOD, parseSessionQueueRequest} = await import("./SessionQueue"); +const {SESSION_SEARCH_METHOD, SESSION_ATTACHMENTS_METHOD, sessionSearchParser, sessionAttachmentsParser} = await import("./SessionDiscovery"); +const {SESSION_ARCHIVE_METHOD, SESSION_UNARCHIVE_METHOD, sessionArchiveParser} = await import("./SessionArchive"); + +const acp = await import("@agentclientprotocol/sdk"); +const {z} = await import("zod"); +const {startCodexConnection} = await import("./CodexJsonRpcConnection"); +const {CodexAcpServer} = await import("./CodexAcpServer"); +const {createJsonStream} = await import("./StdUtils"); +const {isCodexAuthRequest} = await import("./CodexAuthMethod"); +const {CodexAcpClient} = await import("./CodexAcpClient"); +const {CodexAppServerClient} = await import("./CodexAppServerClient"); +const {default: packageJson} = await import("../package.json"); +const {logger} = await import("./Logger"); +const {runLoginCommand} = await import("./login"); +const {runCodexCli} = await import("./CodexCli"); +const {prepareCodexHookConfig} = await import("./CodexHookConfig"); +const {CODEX_HOOKS_LIST_METHOD, CODEX_HOOKS_TRUST_METHOD} = await import("./CodexHookTrust"); +const { GOAL_CONTROL_METHOD, LEGACY_SET_SESSION_MODEL_METHOD, SESSION_STEERING_METHOD, -} from "./AcpExtensions"; -import {ASYNC_TASK_STOP_METHOD} from "./async-tasks/AsyncTaskExtension"; +} = await import("./AcpExtensions"); +const {ASYNC_TASK_STOP_METHOD} = await import("./async-tasks/AsyncTaskExtension"); -import {SESSION_REWIND_METHOD} from "./SessionRewind"; +const {RUNTIME_READ_METHOD, RUNTIME_CONTROL_METHOD, runtimeReadParser, runtimeControlParser} = await import("./SessionRuntime"); +const {SESSION_REWIND_METHOD} = await import("./SessionRewind"); const emptyExtensionParamsParser = z.preprocess( (params) => params ?? {}, @@ -138,7 +149,7 @@ function startAcpServer() { const acpJsonStream = createJsonStream(process.stdin, process.stdout); - function createAgent(connection: acp.AgentContext): CodexAcpServer { + function createAgent(connection: AcpTypes.AgentContext): CodexAcpServerType { const appServerClient = new CodexAppServerClient(codexProcessState.connection.connection); const codexClient = new CodexAcpClient(appServerClient, hookConfig.sessionConfig, modelProvider); return new CodexAcpServer( @@ -151,8 +162,8 @@ function startAcpServer() { ); } - let codexAcpServer: CodexAcpServer | null = null; - const getAgent = (): CodexAcpServer => { + let codexAcpServer: CodexAcpServerType | null = null; + const getAgent = (): CodexAcpServerType => { if (!codexAcpServer) { throw acp.RequestError.internalError("ACP agent is not connected"); } @@ -191,6 +202,14 @@ function startAcpServer() { .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(ASYNC_TASK_STOP_METHOD, asyncTaskStopParamsParser, (ctx) => getAgent().extMethod(ASYNC_TASK_STOP_METHOD, ctx.params)) + .onRequest(SESSION_FILE_REVERT_METHOD, sessionFileRevertParser, (ctx) => getAgent().revertFiles(ctx.params)) + .onRequest(SESSION_QUEUE_METHOD, z.unknown().transform(value => parseSessionQueueRequest(value)), (ctx) => getAgent().manageSessionQueue(ctx.params, ctx.signal)) + .onRequest(SESSION_SEARCH_METHOD, sessionSearchParser, (ctx) => getAgent().searchSessionHistory(ctx.params)) + .onRequest(SESSION_ATTACHMENTS_METHOD, sessionAttachmentsParser, (ctx) => getAgent().manageSessionAttachments(ctx.params)) + .onRequest(SESSION_ARCHIVE_METHOD, sessionArchiveParser, (ctx) => getAgent().archiveSession(ctx.params.sessionId, true)) + .onRequest(SESSION_UNARCHIVE_METHOD, sessionArchiveParser, (ctx) => getAgent().archiveSession(ctx.params.sessionId, false)) + .onRequest(RUNTIME_READ_METHOD, runtimeReadParser, (ctx) => getAgent().readSessionRuntime(ctx.params, ctx.signal)) + .onRequest(RUNTIME_CONTROL_METHOD, runtimeControlParser, (ctx) => getAgent().controlSessionRuntime(ctx.params)) .onRequest(SESSION_REWIND_METHOD, sessionRewindParamsParser, (ctx) => getAgent().extMethod(SESSION_REWIND_METHOD, ctx.params)) .onRequest(CODEX_HOOKS_LIST_METHOD, hooksListParamsParser, (ctx) => getAgent().listHooks(ctx.params.cwd)) .onRequest(CODEX_HOOKS_TRUST_METHOD, hooksTrustParamsParser, (ctx) => getAgent().trustHooks(ctx.params.cwd, ctx.params.hooks)) diff --git a/src/tool-calls/reporters/FuzzySearchReporter.ts b/src/tool-calls/reporters/FuzzySearchReporter.ts index ec03b2114..19f4d34aa 100644 --- a/src/tool-calls/reporters/FuzzySearchReporter.ts +++ b/src/tool-calls/reporters/FuzzySearchReporter.ts @@ -19,7 +19,7 @@ export class FuzzySearchReporter { report: started ? "start" : "update", title: searchTitle(event.query, null), status: "in_progress", - locations: event.files.map(file => path.isAbsolute(file.path) ? file.path : path.join(file.root, file.path)), + locations: event.files.map(file => path.isAbsolute(file.path) ? file.path : (file.root.startsWith("/") ? path.posix : path).join(file.root, file.path)), }; return started ? {...facts, kind: "search", input: {query: event.query}} : facts; }