From 34a3d5cd442ee65b4b984635d32e4d02cb58c9ca Mon Sep 17 00:00:00 2001 From: Nikita Ashikhmin Date: Wed, 23 Sep 2026 15:14:22 +0400 Subject: [PATCH 01/14] feat: add the ACP diff patch capability Send compact Git patches when both the client and the adapter advertise diffPatch. Keep the standard diff text fields for a client without the capability. The patch builders: - strip the leading slash of an absolute path, so a header reads a/workspace/App.ts and not a//workspace/App.ts; - quote a path that Git quotes; - add rename from and rename to headers and use the target path; - replace the file headers that Codex supplied, so that all headers agree; - add new file mode and deleted file mode headers; - keep carriage returns and mark a missing final newline. A patch is built only when it has at least one valid hunk, holds no binary content and is at most 1 MiB. Otherwise the adapter sends the standard diff with the file texts. This covers an empty file, a pure rename and malformed Codex hunks. Before, the adapter dropped the change when the diff did not parse. The legacy already-patched branch now reports the move target path. Stop sending the ACP diff statistics. A client can derive the line counts from a negotiated patch or from the standard diff texts. The diff statistics contract and its documentation are removed. docs/diff-patch-extension.md describes the negotiation, the patch metadata, the placeholder text fields, the header paths and quoting, the file mode and rename headers, the kept bytes, the final newline marker and every fallback case. --- CHANGELOG.md | 1 - README.md | 1 + docs/diff-patch-extension.md | 124 +++++++++++++ docs/diff-statistics-extension.md | 90 --------- readme-dev.md | 4 - src/AirExtension.ts | 2 +- src/CodexAcpServer.ts | 8 +- src/CodexEventHandler.ts | 3 +- src/CodexToolCallMapper.ts | 111 ++++++++--- src/DiffStats.ts | 74 -------- src/GitPatch.ts | 174 ++++++++++++++++++ src/__tests__/AirExtension.test.ts | 16 ++ .../data/file-change-add-multiple-files.json | 24 +-- .../data/file-change-add-new-file.json | 12 +- .../data/file-change-add-raw-content.json | 12 +- .../data/file-change-delete-file.json | 12 +- .../data/file-change-delete-raw-content.json | 12 +- .../data/load-session-history.json | 12 +- .../CodexACPAgent/file-change-events.test.ts | 157 +++++++++++++++- .../CodexACPAgent/initialize.test.ts | 2 +- src/__tests__/DiffStats.test.ts | 114 ------------ src/__tests__/GitPatch.test.ts | 96 ++++++++++ 22 files changed, 659 insertions(+), 402 deletions(-) create mode 100644 docs/diff-patch-extension.md delete mode 100644 docs/diff-statistics-extension.md delete mode 100644 src/DiffStats.ts create mode 100644 src/GitPatch.ts create mode 100644 src/__tests__/AirExtension.test.ts delete mode 100644 src/__tests__/DiffStats.test.ts create mode 100644 src/__tests__/GitPatch.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 23927e0c..48819a5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,7 +39,6 @@ ### Performance Improvements * derive file change reports from turn diffs ([#518](https://github.com/agentclientprotocol/codex-acp/issues/518)) ([caddefe](https://github.com/agentclientprotocol/codex-acp/commit/caddefe56ff55a3f0827aa8ad60d03779f168425)) -* supply validated diff statistics to ACP clients ([#501](https://github.com/agentclientprotocol/codex-acp/issues/501)) ([989a8f1](https://github.com/agentclientprotocol/codex-acp/commit/989a8f1735f2465f3db2e8acfa00a4da8f352c00)) ## [1.11.0](https://github.com/agentclientprotocol/codex-acp/compare/v1.10.0...v1.11.0) (2026-09-09) diff --git a/README.md b/README.md index f43ad986..1864792d 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ Use [OpenAI Codex](https://github.com/openai/codex) from [Agent Client Protocol] - Concrete recommended model and reasoning-effort values through the opt-in [AIR recommended config values](docs/recommended-config-values-extension.md) capability. - Text prompts, embedded context, images, resource links, and additional workspace directories. - Shell command, file change, [permission request](docs/permission-extension.md), MCP tool call, terminal output, reasoning, plan, web search, image generation, image view, token usage, and review events. +- Compact file changes through the negotiated [AIR diff patch extension](docs/diff-patch-extension.md). - [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). diff --git a/docs/diff-patch-extension.md b/docs/diff-patch-extension.md new file mode 100644 index 00000000..1f614613 --- /dev/null +++ b/docs/diff-patch-extension.md @@ -0,0 +1,124 @@ +# AIR diff patch extension + +Status: Experimental + +This extension lets an ACP agent send one compact Git patch instead of file text snapshots. +It applies to an ACP `diff` content block. + +## Capability negotiation + +The client advertises `diffPatch` in the initialize request: + +```json +{ + "clientCapabilities": { + "_meta": { + "jetbrains": { + "air": { + "version": 1, + "capabilities": ["diffPatch"] + } + } + } + } +} +``` + +The adapter advertises the same capability in the initialize response: + +```json +{ + "_meta": { + "jetbrains": { + "air": { + "version": 1, + "capabilities": ["diffPatch"] + } + } + } +} +``` + +The adapter uses patch mode only when both peers advertise `diffPatch` with an integer AIR envelope version of at least 1. +If either declaration is absent or malformed, the adapter sends the standard `oldText` and `newText` values. + +## Diff content + +Patch mode puts the payload at `_meta.jetbrains.air.diffPatch`: + +```json +{ + "type": "diff", + "path": "/workspace/src/App.ts", + "oldText": null, + "newText": "", + "_meta": { + "kind": "update", + "jetbrains": { + "air": { + "version": 1, + "diffPatch": { + "version": 1, + "format": "git_patch", + "text": "diff --git a/workspace/src/App.ts b/workspace/src/App.ts\n--- a/workspace/src/App.ts\n+++ b/workspace/src/App.ts\n@@ -1 +1 @@\n-old\n+new\n" + } + } + } + } +} +``` + +| Field | Type | Meaning | +| --- | --- | --- | +| `version` | integer | Must equal `1`. | +| `format` | string | Must equal `git_patch`. | +| `text` | string | One unified Git patch for the block's file. | + +The patch contains Git file headers and at least one `@@` hunk. +Each header path is the absolute file path without its leading slash, with the `a/` or `b/` prefix. +A Windows path uses forward slashes, for example `a/C:/work/App.ts`. +The adapter quotes a path in C style when it contains a double quote, a backslash or a control character, as Git does. +It does not quote non-ASCII characters. +A `---` or `+++` line ends with a tab when its unquoted path contains a space. + +An added file has a `new file mode 100644` header and uses `/dev/null` as the old file header. +A deleted file has a `deleted file mode 100644` header and uses `/dev/null` as the new file header. +A moved file has `rename from` and `rename to` headers, and the block `path` is the target path. +The patch keeps the provider bytes, including a carriage return. +A file without a final newline ends with the `\ No newline at end of file` marker. + +In patch mode, `oldText: null` and `newText: ""` are compatibility placeholders. +They are not file snapshots or changed fragments. +The receiver must use `diffPatch.text` as the change payload after it accepts the negotiated extension. + +The receiver derives line counts and changed fragments from the patch. + +## Compatibility and fallback + +The adapter sends the standard ACP diff when it cannot build a valid patch. +That fallback contains meaningful `oldText` and `newText` values and omits `diffPatch`. +The adapter uses the fallback in these cases: + +- The file is empty, so no hunk can express it. +- The content is binary. The adapter treats content as binary when its first 8000 characters contain a NUL character. +- The patch text is larger than 1 MiB (`DIFF_PATCH_MAX_BYTES`). +- A pure rename has no hunk. +- The update hunks from Codex are malformed. + +For an update, the fallback reads the file and applies the Codex hunks. +When the adapter cannot parse the hunks or apply them, it omits the block and logs the change. + +A receiver accepts the patch only after bilateral negotiation. +It also validates both versions, the format, and the patch text. +If validation fails, the receiver ignores `diffPatch` and reads the standard text fields. +Unknown fields do not invalidate a valid payload. + +## Codex behavior + +Codex App Server supplies compact hunks for updates and file content for additions and deletions. +The adapter checks the update hunks and puts its own Git headers before them. +It drops the file headers that Codex supplied, so that all headers name the same paths. +It builds one full-file patch for an addition or deletion because the provider already supplied that content. + +The adapter applies this mode to live file changes and replayed session history. +It does not read the current file when it can forward a provider patch. diff --git a/docs/diff-statistics-extension.md b/docs/diff-statistics-extension.md deleted file mode 100644 index a493f2b9..00000000 --- a/docs/diff-statistics-extension.md +++ /dev/null @@ -1,90 +0,0 @@ -# AIR diff statistics extension - -Status: Experimental - -Agents can attach added and removed line counts to an ACP `diff` content block. -Clients use these values without comparing the block's texts again. -The extension applies to any ACP agent, including Codex. - -## Wire format - -The payload belongs to the individual diff block at `_meta.jetbrains.air.diffStats`. - -```json -{ - "type": "diff", - "path": "/project/file.txt", - "oldText": "old\n", - "newText": "new\nextra\n", - "_meta": { - "kind": "update", - "jetbrains": { - "air": { - "version": 1, - "diffStats": { - "version": 1, - "added": 2, - "removed": 1 - } - } - } - } -} -``` - -`jetbrains.air.version` identifies the AIR envelope. Clients accept integer versions of at least 1. -`diffStats.version` identifies this payload. This specification defines version 1 only. -Agents preserve other metadata, including `kind`. - -| Field | Type | Meaning | -| --- | --- | --- | -| `version` | integer | Must equal `1`. | -| `added` | integer | Number of added lines, between 0 and 2147483647. | -| `removed` | integer | Number of removed lines, between 0 and 2147483647. | - -All three fields are required. Numeric strings are invalid. -Statistics contain no navigation coordinates. Clients must not compare texts to obtain coordinates when they receive valid counts. - -## Count semantics - -For updates, counts describe the addition and deletion operations in the supplied patch. -Context lines and `No newline at end of file` markers do not contribute to counts. -A replacement contributes both added and removed lines. -A patch can contain operations that leave the normalized file content unchanged. -Clients preserve the patch counts instead of recomputing a minimal diff. -Relocating an exact hunk does not change its counts. - -For creation and deletion, count the supplied file content. -Treat CRLF and CR as line boundaries and do not count an extra line after the final terminator. -An empty string has zero lines. One line terminator represents one empty line. -Creation has zero removed lines; deletion has zero added lines. - -Each diff block owns its statistics. -A text revision carries statistics for that revision, or omits the payload. -Clients invalidate old statistics when the texts change. -Status-only updates preserve previous statistics. -Late statistics may replace calculated values for unchanged texts. - -## Availability and compatibility - -This is optional display metadata. No capability negotiation is required. -Clients that do not understand it can ignore it and render the standard diff content. -Agents still send the usual `path`, `oldText`, and `newText` values. - -An agent omits statistics when it cannot produce valid counts. -Clients use their normal comparison when metadata is missing, malformed, or unsupported. -Unknown fields do not invalidate a valid payload. - -The earlier experimental `com.intellij/diffStats` key is not part of this contract. -AIR ignores that key and uses its normal fallback. -Existing persisted statistics, including stored navigation lines, remain readable without migration. - -## Codex behavior - -The existing patch application validates the file change and produces the texts for ACP. -The statistics calculator then reads only the parsed patch. It receives no file texts. -It validates hunk sizes and coordinates and counts `+` and `-` operations. -It does not verify file contents again or locate a navigation line. - -Tests: `src/__tests__/DiffStats.test.ts` and -`src/__tests__/CodexACPAgent/file-change-events.test.ts`. diff --git a/readme-dev.md b/readme-dev.md index e690116f..e12959d2 100644 --- a/readme-dev.md +++ b/readme-dev.md @@ -103,7 +103,3 @@ Command replies, review results, and terminal/retrying errors retain their exist failure channels. Clients advertising session compaction support continue to receive the dedicated compaction lifecycle instead of the legacy completion advisory. -### AIR diff statistics - -See the [diff statistics specification](docs/diff-statistics-extension.md) for the -`_meta.jetbrains.air.diffStats` payload and its compatibility rules. diff --git a/src/AirExtension.ts b/src/AirExtension.ts index 28af8784..a2b52cfc 100644 --- a/src/AirExtension.ts +++ b/src/AirExtension.ts @@ -12,7 +12,7 @@ export const JETBRAINS_META_KEY = "jetbrains"; export const AIR_META_KEY = "air"; export const AIR_EXTENSION_VERSION_KEY = "version"; export const AIR_EXTENSION_CAPABILITIES_KEY = "capabilities"; -export const AIR_DIFF_STATS_KEY = "diffStats"; +export const AIR_DIFF_PATCH_KEY = "diffPatch"; export const AIR_SESSION_FAILURE_KEY = "sessionFailure"; export const AIR_AGENT_FILE_CHANGE_REPORT_KEY = "agentFileChangeReport"; export const AIR_NATIVE_SUBAGENT_SESSIONS_KEY = "nativeSubagentSessions"; diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index e640ae1d..ca51a7ac 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -138,6 +138,7 @@ import {once} from "node:events"; import { AIR_AGENT_FILE_CHANGE_REPORT_KEY, AIR_ASYNC_TASKS_KEY, + AIR_DIFF_PATCH_KEY, AIR_NATIVE_SUBAGENT_SESSIONS_KEY, AIR_RECOMMENDED_CONFIG_VALUE_KEY, AIR_EXTENSION_CAPABILITIES_KEY, @@ -421,6 +422,7 @@ export class CodexAcpServer { [AIR_EXTENSION_VERSION_KEY]: AIR_EXTENSION_VERSION, [AIR_EXTENSION_CAPABILITIES_KEY]: [ AIR_SESSION_FAILURE_KEY, + AIR_DIFF_PATCH_KEY, AIR_AGENT_FILE_CHANGE_REPORT_KEY, AIR_NATIVE_SUBAGENT_SESSIONS_KEY, AIR_ASYNC_TASKS_KEY, @@ -2290,7 +2292,10 @@ export class CodexAcpServer { case "reasoning": return this.createReasoningUpdates(item); case "fileChange": - return [await createFileChangeUpdate(item)]; + return [await createFileChangeUpdate( + item, + clientSupportsAirCapability(this.clientCapabilities, AIR_DIFF_PATCH_KEY), + )]; case "commandExecution": { const updates = [await createCommandExecutionUpdate(item)]; const completeUpdate = createCommandExecutionCompleteUpdate(item, sessionState.terminalOutputMode); @@ -2863,6 +2868,7 @@ export class CodexAcpServer { agentFileChangeReportRequest !== null, clientSupportsCompaction(this.clientCapabilities), clientSupportsNotices(this.clientCapabilities), + clientSupportsAirCapability(this.clientCapabilities, AIR_DIFF_PATCH_KEY), ); eventHandler = promptEventHandler; const permissionLifecycle = this.permissionLifecycleContext(sessionState); diff --git a/src/CodexEventHandler.ts b/src/CodexEventHandler.ts index 57727d59..cb776f10 100644 --- a/src/CodexEventHandler.ts +++ b/src/CodexEventHandler.ts @@ -251,6 +251,7 @@ export class CodexEventHandler { collectTurnDiffs = false, private readonly supportsCompaction = false, private readonly supportsNotices = false, + private readonly supportsDiffPatch = false, ) { this.onAccountUpdated = onAccountUpdated; this.sessionState = sessionState; @@ -816,7 +817,7 @@ export class CodexEventHandler { private async createItemEvent(event: ItemStartedNotification): Promise { switch (event.item.type) { case "fileChange": - return await createFileChangeUpdate(event.item); + return await createFileChangeUpdate(event.item, this.supportsDiffPatch); case "commandExecution": { if (commandExecutionUsesTerminalOutput(event.item)) { this.terminalCommandIds.add(event.item.id); diff --git a/src/CodexToolCallMapper.ts b/src/CodexToolCallMapper.ts index d7351503..4cc17d3d 100644 --- a/src/CodexToolCallMapper.ts +++ b/src/CodexToolCallMapper.ts @@ -1,7 +1,7 @@ import type { ContentBlock, ToolCallContent } from "@agentclientprotocol/sdk"; import { applyPatch, parsePatch, reversePatch, type StructuredPatch } from "diff"; -import { DiffStatsCalculator } from "./DiffStats"; -import { AIR_DIFF_STATS_KEY, withAirMeta } from "./AirExtension"; +import { AIR_DIFF_PATCH_KEY, withAirMeta } from "./AirExtension"; +import { createAddedFileGitPatch, createDeletedFileGitPatch, createUpdateGitPatch } from "./GitPatch"; import { readFile } from "node:fs/promises"; import path from "node:path"; import type { UpdateSessionEvent } from "./ACPSessionConnection"; @@ -50,8 +50,6 @@ type ContextCompactionItem = ThreadItem & { type: "contextCompaction" }; type AcpToolCallEvent = Extract; const CONTEXT_COMPACTION_META = createContextCompactionMeta(); -const DIFF_STATS = new DiffStatsCalculator(); - function toAcpStatus(status: CodexItemStatus): AcpToolCallStatus { switch (status) { case "inProgress": @@ -66,11 +64,12 @@ function toAcpStatus(status: CodexItemStatus): AcpToolCallStatus { } export async function createFileChangeUpdate( - item: ThreadItem & { type: "fileChange" } + item: ThreadItem & { type: "fileChange" }, + supportsDiffPatch = false, ): Promise { const patches: ToolCallContent[] = []; for (const change of item.changes) { - const content = await createPatchContent(change); + const content = await createPatchContent(change, supportsDiffPatch); if (content) patches.push(content); // ignore unparseable diffs } @@ -829,15 +828,18 @@ function createContent(content: ContentBlock): ToolCallContent { }; } -async function createPatchContent(change: FileUpdateChange): Promise { +async function createPatchContent( + change: FileUpdateChange, + supportsDiffPatch: boolean, +): Promise { try { switch (change.kind.type) { case "add": - return await createAddFileContent(change); + return createAddFileContent(change, supportsDiffPatch); case "delete": - return await createDeleteFileContent(change); + return createDeleteFileContent(change, supportsDiffPatch); case "update": - return await createUpdateFileContent(change); + return await createUpdateFileContent(change, change.kind.move_path, supportsDiffPatch); } } catch (error) { logger.log(`Error processing file update change: ${error}`); @@ -845,24 +847,43 @@ async function createPatchContent(change: FileUpdateChange): Promise { +function createAddFileContent( + change: FileUpdateChange, + supportsDiffPatch: boolean, +): ToolCallContent { + // app-server always returns file content instead of diff + const patch = supportsDiffPatch ? createAddedFileGitPatch(change.path, change.diff) : null; + if (patch !== null) { + return createPatchOnlyContent(change.path, "add", patch); + } return { type: "diff", oldText: null, - newText: change.diff, // app-server always returns file content instead of diff + newText: change.diff, path: change.path, - _meta: withAirMeta({ kind: "add" }, AIR_DIFF_STATS_KEY, DIFF_STATS.addedFile(change.diff)), + _meta: { kind: "add" }, }; } -async function createUpdateFileContent(change: FileUpdateChange): Promise { - if (change.kind.type !== "update") return null; - +async function createUpdateFileContent( + change: FileUpdateChange, + movePath: string | null, + supportsDiffPatch: boolean, +): Promise { const unifiedDiff = recoverCorruptedDiff(change.diff); - const patches = parsePatch(unifiedDiff); - if (patches.length !== 1) return null; - const patch = patches[0]!; - const movePath = change.kind.move_path; + const targetPath = movePath ?? change.path; + + const gitPatch = supportsDiffPatch ? createUpdateGitPatch(change.path, targetPath, unifiedDiff) : null; + if (gitPatch !== null) { + return createPatchOnlyContent(targetPath, "update", gitPatch); + } + + // The standard diff needs the file text, so it reads the file and applies the Codex hunks. + const patch = parseSinglePatch(unifiedDiff); + if (patch === null) { + logger.log("Skipped a file change whose diff has no single valid patch", {path: change.path}); + return null; + } const oldContent = await readFileContent(change.path); if (oldContent !== null) { @@ -872,11 +893,11 @@ async function createUpdateFileContent(change: FileUpdateChange): Promise { +function createDeleteFileContent( + change: FileUpdateChange, + supportsDiffPatch: boolean, +): ToolCallContent { + // app-server always returns file content instead of diff + const patch = supportsDiffPatch ? createDeletedFileGitPatch(change.path, change.diff) : null; + if (patch !== null) { + return createPatchOnlyContent(change.path, "delete", patch); + } return { type: "diff", - oldText: change.diff, // app-server always returns file content instead of diff + oldText: change.diff, newText: "", path: change.path, - _meta: withAirMeta({ kind: "delete" }, AIR_DIFF_STATS_KEY, DIFF_STATS.deletedFile(change.diff)) - } + _meta: { kind: "delete" }, + }; +} + +function createPatchOnlyContent(path: string, kind: string, patch: string): ToolCallContent { + return { + type: "diff", + oldText: null, + newText: "", + path, + _meta: withAirMeta({ kind }, AIR_DIFF_PATCH_KEY, { + version: 1, + format: "git_patch", + text: patch, + }), + }; } async function readFileContent(filePath: string): Promise { diff --git a/src/DiffStats.ts b/src/DiffStats.ts deleted file mode 100644 index 267e70ab..00000000 --- a/src/DiffStats.ts +++ /dev/null @@ -1,74 +0,0 @@ -import type { StructuredPatch } from "diff"; - -export type DiffStats = { - version: 1; - added: number; - removed: number; -}; - -export class DiffStatsCalculator { - addedFile(text: string): DiffStats { - return { version: 1, added: this.lineCount(text), removed: 0 }; - } - - deletedFile(text: string): DiffStats { - return { version: 1, added: 0, removed: this.lineCount(text) }; - } - - update(patch: StructuredPatch): DiffStats | null { - if (patch.isBinary || patch.hunks.length === 0) return null; - let added = 0; - let removed = 0; - let previousOldEnd = 1; - let previousNewEnd = 1; - for (const hunk of patch.hunks) { - const { oldStart, oldLines, newStart, newLines } = hunk; - if (![oldStart, oldLines, newStart, newLines].every(Number.isSafeInteger) || - oldStart < previousOldEnd || newStart < previousNewEnd || oldLines < 0 || newLines < 0 || - newStart - oldStart !== added - removed) return null; - let oldConsumed = 0; - let newConsumed = 0; - let previousWasContent = false; - for (const line of hunk.lines) { - switch (line[0]) { - case '+': - added++; - newConsumed++; - previousWasContent = true; - break; - case '-': - removed++; - oldConsumed++; - previousWasContent = true; - break; - case ' ': - case undefined: - oldConsumed++; - newConsumed++; - previousWasContent = true; - break; - case '\\': - if (!previousWasContent || line.replace(/\r$/, '') !== '\\ No newline at end of file') return null; - previousWasContent = false; - break; - default: - return null; - } - } - if (oldConsumed !== oldLines || newConsumed !== newLines) return null; - previousOldEnd = oldStart + oldLines; - previousNewEnd = newStart + newLines; - } - return { version: 1, added, removed }; - } - - private lineCount(text: string): number { - let count = 0; - for (let offset = text.indexOf('\n'); offset >= 0; offset = text.indexOf('\n', offset + 1)) count++; - for (let offset = text.indexOf('\r'); offset >= 0; offset = text.indexOf('\r', offset + 1)) { - if (text[offset + 1] !== '\n') count++; - } - if (text.length > 0 && !text.endsWith('\n') && !text.endsWith('\r')) count++; - return count; - } -} diff --git a/src/GitPatch.ts b/src/GitPatch.ts new file mode 100644 index 00000000..2b8a9763 --- /dev/null +++ b/src/GitPatch.ts @@ -0,0 +1,174 @@ +/** + * Builds the unified Git patches of the AIR diff patch extension. + * + * Each builder returns `null` when it cannot build a patch with at least one valid hunk. + * The caller then sends the standard ACP diff. + */ + +/** The largest patch text that the adapter sends. A larger change uses the standard ACP diff. */ +export const DIFF_PATCH_MAX_BYTES = 1024 * 1024; + +/** Git reads a file as binary when its first 8000 bytes contain a NUL byte. */ +const BINARY_PROBE_LENGTH = 8000; +const NO_NEWLINE_MARKER = "\\ No newline at end of file"; +const HUNK_HEADER = /^@@ -\d+(?:,(\d+))? \+\d+(?:,(\d+))? @@/; +const REGULAR_FILE_MODE = "100644"; + +/** + * Adds Git file headers to the hunks that Codex supplied for an update. + * + * The builder drops the file headers that Codex supplied, so that all headers name the same paths. + * It keeps the hunk bytes, including a carriage return. + */ +export function createUpdateGitPatch(oldPath: string, newPath: string, diff: string): string | null { + const hunks = hunkText(diff); + if (hunks === null || isBinary(hunks)) return null; + const oldName = gitPath(oldPath); + const newName = gitPath(newPath); + const headers = [`diff --git ${quotedGitName("a/", oldName)} ${quotedGitName("b/", newName)}`]; + if (oldName !== newName) { + headers.push(`rename from ${quotedGitName("", oldName)}`, `rename to ${quotedGitName("", newName)}`); + } + headers.push(fileHeader("---", "a/", oldName), fileHeader("+++", "b/", newName)); + return limited(`${headers.join("\n")}\n${hunks}`); +} + +/** Builds a whole-file patch for an added file. */ +export function createAddedFileGitPatch(filePath: string, text: string): string | null { + return createWholeFilePatch(filePath, text, "added"); +} + +/** Builds a whole-file patch for a deleted file. */ +export function createDeletedFileGitPatch(filePath: string, text: string): string | null { + return createWholeFilePatch(filePath, text, "deleted"); +} + +function createWholeFilePatch(filePath: string, text: string, change: "added" | "deleted"): string | null { + // A hunk cannot express an empty file, and a binary file has no text lines. + if (text.length === 0 || isBinary(text)) return null; + const name = gitPath(filePath); + const lines = text.split("\n"); + const endsWithNewline = lines.at(-1) === ""; + if (endsWithNewline) lines.pop(); + const sign = change === "added" ? "+" : "-"; + const range = `1${lines.length === 1 ? "" : `,${lines.length}`}`; + const patch = [ + `diff --git ${quotedGitName("a/", name)} ${quotedGitName("b/", name)}`, + `${change === "added" ? "new" : "deleted"} file mode ${REGULAR_FILE_MODE}`, + change === "added" ? "--- /dev/null" : fileHeader("---", "a/", name), + change === "added" ? fileHeader("+++", "b/", name) : "+++ /dev/null", + change === "added" ? `@@ -0,0 +${range} @@` : `@@ -${range} +0,0 @@`, + ...lines.map(line => `${sign}${line}`), + ...(endsWithNewline ? [] : [NO_NEWLINE_MARKER]), + "", + ].join("\n"); + return limited(patch); +} + +/** + * Returns the hunks of a Codex diff, or `null` when a hunk is malformed. + * Codex can put file headers before the first hunk. They are dropped. + */ +function hunkText(diff: string): string | null { + const lines = diff.split("\n"); + if (lines.at(-1) === "") lines.pop(); + const first = lines.findIndex(line => line.startsWith("@@")); + if (first < 0) return null; + const leading = lines.slice(0, first); + if (!leading.every(isFileHeaderLine)) return null; + const hunks = lines.slice(first); + + let index = 0; + while (index < hunks.length) { + const header = HUNK_HEADER.exec(hunks[index]!); + if (header === null) return null; + let oldLines = header[1] === undefined ? 1 : Number(header[1]); + let newLines = header[2] === undefined ? 1 : Number(header[2]); + index++; + while (oldLines > 0 || newLines > 0) { + const line = hunks[index]; + if (line === undefined) return null; + switch (line[0]) { + case " ": + case undefined: + oldLines--; + newLines--; + break; + case "-": + oldLines--; + break; + case "+": + newLines--; + break; + case "\\": + break; + default: + return null; + } + index++; + } + if (oldLines !== 0 || newLines !== 0) return null; + while (hunks[index]?.startsWith("\\")) index++; + } + return `${hunks.join("\n")}\n`; +} + +function isFileHeaderLine(line: string): boolean { + return line.length === 0 + || line.startsWith("diff --git ") + || line.startsWith("index ") + || line.startsWith("--- ") + || line.startsWith("+++ "); +} + +function isBinary(text: string): boolean { + return text.slice(0, BINARY_PROBE_LENGTH).includes("\0"); +} + +function limited(patch: string): string | null { + return Buffer.byteLength(patch, "utf8") <= DIFF_PATCH_MAX_BYTES ? patch : null; +} + +/** + * Converts a file path to a Git path without the leading slash. + * For example, `/workspace/src/App.ts` becomes `workspace/src/App.ts`. + * A Windows path such as `C:\work\App.ts` becomes `C:/work/App.ts`. + */ +function gitPath(filePath: string): string { + const slashed = /^[A-Za-z]:\\/.test(filePath) ? filePath.replace(/\\/g, "/") : filePath; + return slashed.replace(/^\/+/, ""); +} + +/** A `---` or `+++` line ends with a tab when an unquoted name contains a space, as Git does. */ +function fileHeader(marker: "---" | "+++", prefix: string, name: string): string { + const quoted = quotedGitName(prefix, name); + return `${marker} ${quoted}${!quoted.startsWith("\"") && quoted.includes(" ") ? "\t" : ""}`; +} + +/** + * Quotes a name as Git does when the name contains a double quote, a backslash or a control character. + * The adapter keeps non-ASCII characters, like Git with `core.quotePath=false`. + */ +function quotedGitName(prefix: string, name: string): string { + const full = `${prefix}${name}`; + if (!/["\\\x00-\x1f\x7f]/.test(full)) return full; + let quoted = ""; + for (const char of full) { + switch (char) { + case "\"": quoted += "\\\""; break; + case "\\": quoted += "\\\\"; break; + case "\x07": quoted += "\\a"; break; + case "\b": quoted += "\\b"; break; + case "\t": quoted += "\\t"; break; + case "\n": quoted += "\\n"; break; + case "\v": quoted += "\\v"; break; + case "\f": quoted += "\\f"; break; + case "\r": quoted += "\\r"; break; + default: { + const code = char.codePointAt(0)!; + quoted += code < 0x20 || code === 0x7f ? `\\${code.toString(8).padStart(3, "0")}` : char; + } + } + } + return `"${quoted}"`; +} diff --git a/src/__tests__/AirExtension.test.ts b/src/__tests__/AirExtension.test.ts new file mode 100644 index 00000000..a30c4e56 --- /dev/null +++ b/src/__tests__/AirExtension.test.ts @@ -0,0 +1,16 @@ +import {describe, expect, it} from "vitest"; +import {AIR_DIFF_PATCH_KEY, clientSupportsAirCapability} from "../AirExtension"; + +describe("clientSupportsAirCapability", () => { + const air = (value: unknown) => ({_meta: {jetbrains: {air: value}}}); + + it("accepts the diff patch capability only with a valid AIR declaration", () => { + expect(clientSupportsAirCapability(air({version: 1, capabilities: [AIR_DIFF_PATCH_KEY]}), AIR_DIFF_PATCH_KEY)).toBe(true); + expect(clientSupportsAirCapability(null, AIR_DIFF_PATCH_KEY)).toBe(false); + expect(clientSupportsAirCapability({}, AIR_DIFF_PATCH_KEY)).toBe(false); + expect(clientSupportsAirCapability(air({version: 1, capabilities: []}), AIR_DIFF_PATCH_KEY)).toBe(false); + expect(clientSupportsAirCapability(air({version: 0, capabilities: [AIR_DIFF_PATCH_KEY]}), AIR_DIFF_PATCH_KEY)).toBe(false); + expect(clientSupportsAirCapability(air({version: "1", capabilities: [AIR_DIFF_PATCH_KEY]}), AIR_DIFF_PATCH_KEY)).toBe(false); + expect(clientSupportsAirCapability(air({version: 1.5, capabilities: [AIR_DIFF_PATCH_KEY]}), AIR_DIFF_PATCH_KEY)).toBe(false); + }); +}); diff --git a/src/__tests__/CodexACPAgent/data/file-change-add-multiple-files.json b/src/__tests__/CodexACPAgent/data/file-change-add-multiple-files.json index 26848a01..41b58b87 100644 --- a/src/__tests__/CodexACPAgent/data/file-change-add-multiple-files.json +++ b/src/__tests__/CodexACPAgent/data/file-change-add-multiple-files.json @@ -16,17 +16,7 @@ "newText": "class FileA\n", "path": "/test/project/FileA.kt", "_meta": { - "kind": "add", - "jetbrains": { - "air": { - "version": 1, - "diffStats": { - "version": 1, - "added": 1, - "removed": 0 - } - } - } + "kind": "add" } }, { @@ -35,17 +25,7 @@ "newText": "class FileB\n", "path": "/test/project/FileB.kt", "_meta": { - "kind": "add", - "jetbrains": { - "air": { - "version": 1, - "diffStats": { - "version": 1, - "added": 1, - "removed": 0 - } - } - } + "kind": "add" } } ] diff --git a/src/__tests__/CodexACPAgent/data/file-change-add-new-file.json b/src/__tests__/CodexACPAgent/data/file-change-add-new-file.json index 8ede27a2..4bf59ca9 100644 --- a/src/__tests__/CodexACPAgent/data/file-change-add-new-file.json +++ b/src/__tests__/CodexACPAgent/data/file-change-add-new-file.json @@ -16,17 +16,7 @@ "newText": "package test.project\n\nclass NewFile {\n fun hello() = \"Hello\"\n}\n", "path": "/test/project/NewFile.kt", "_meta": { - "kind": "add", - "jetbrains": { - "air": { - "version": 1, - "diffStats": { - "version": 1, - "added": 5, - "removed": 0 - } - } - } + "kind": "add" } } ] diff --git a/src/__tests__/CodexACPAgent/data/file-change-add-raw-content.json b/src/__tests__/CodexACPAgent/data/file-change-add-raw-content.json index 2e009c80..31f18f5a 100644 --- a/src/__tests__/CodexACPAgent/data/file-change-add-raw-content.json +++ b/src/__tests__/CodexACPAgent/data/file-change-add-raw-content.json @@ -16,17 +16,7 @@ "newText": "fun main() {\n println(\"Hello, World!\")\n}\n", "path": "/test/project/RawFile.kt", "_meta": { - "kind": "add", - "jetbrains": { - "air": { - "version": 1, - "diffStats": { - "version": 1, - "added": 3, - "removed": 0 - } - } - } + "kind": "add" } } ] diff --git a/src/__tests__/CodexACPAgent/data/file-change-delete-file.json b/src/__tests__/CodexACPAgent/data/file-change-delete-file.json index 63b0cb18..ea6dd487 100644 --- a/src/__tests__/CodexACPAgent/data/file-change-delete-file.json +++ b/src/__tests__/CodexACPAgent/data/file-change-delete-file.json @@ -16,17 +16,7 @@ "newText": "", "path": "/test/project/OldFile.kt", "_meta": { - "kind": "delete", - "jetbrains": { - "air": { - "version": 1, - "diffStats": { - "version": 1, - "added": 0, - "removed": 3 - } - } - } + "kind": "delete" } } ] diff --git a/src/__tests__/CodexACPAgent/data/file-change-delete-raw-content.json b/src/__tests__/CodexACPAgent/data/file-change-delete-raw-content.json index ba03757b..60f83da7 100644 --- a/src/__tests__/CodexACPAgent/data/file-change-delete-raw-content.json +++ b/src/__tests__/CodexACPAgent/data/file-change-delete-raw-content.json @@ -16,17 +16,7 @@ "newText": "", "path": "/test/project/RawDeleteFile.kt", "_meta": { - "kind": "delete", - "jetbrains": { - "air": { - "version": 1, - "diffStats": { - "version": 1, - "added": 0, - "removed": 3 - } - } - } + "kind": "delete" } } ] diff --git a/src/__tests__/CodexACPAgent/data/load-session-history.json b/src/__tests__/CodexACPAgent/data/load-session-history.json index 9ae79eff..0c9e36f3 100644 --- a/src/__tests__/CodexACPAgent/data/load-session-history.json +++ b/src/__tests__/CodexACPAgent/data/load-session-history.json @@ -295,17 +295,7 @@ "newText": "Hello\nWorld\n", "path": "/test/project/Added.txt", "_meta": { - "kind": "add", - "jetbrains": { - "air": { - "version": 1, - "diffStats": { - "version": 1, - "added": 2, - "removed": 0 - } - } - } + "kind": "add" } } ] diff --git a/src/__tests__/CodexACPAgent/file-change-events.test.ts b/src/__tests__/CodexACPAgent/file-change-events.test.ts index 426fefd0..24ee7cde 100644 --- a/src/__tests__/CodexACPAgent/file-change-events.test.ts +++ b/src/__tests__/CodexACPAgent/file-change-events.test.ts @@ -269,10 +269,10 @@ describe('CodexEventHandler - file change events', () => { }); it.each([ - { name: 'before application', disk: 'old\n', expected: { version: 1, added: 2, removed: 1 } }, - { name: 'after application', disk: 'new\nextra\n', expected: { version: 1, added: 2, removed: 1 } }, - { name: 'a relocated hunk', disk: 'prefix\nold\n', expected: { version: 1, added: 2, removed: 1 } }, - ])('publishes reliable update statistics $name', async ({ disk, expected }) => { + { name: 'before application', disk: 'old\n' }, + { name: 'after application', disk: 'new\nextra\n' }, + { name: 'a relocated hunk', disk: 'prefix\nold\n' }, + ])('publishes legacy update text $name', async ({ disk }) => { mockFileContent('/test/project/OldFile.kt', disk); const event = await createFileChangeUpdate({ type: 'fileChange', @@ -287,10 +287,7 @@ describe('CodexEventHandler - file change events', () => { expect(event.sessionUpdate).toBe('tool_call'); if (event.sessionUpdate !== 'tool_call') throw new Error('Expected a file-change tool call'); expect(event.content).toHaveLength(1); - expect(event.content![0]!._meta).toEqual({ - kind: 'update', - ...(expected ? { jetbrains: { air: { version: 1, diffStats: expected } } } : {}), - }); + expect(event.content![0]!._meta).toEqual({ kind: 'update' }); }); it('should ignore broken unified diffs in update file changes', async () => { @@ -541,4 +538,148 @@ Moved to: /test/project/NewFile.kt`, ], }); }); + + it('should send compact git patches when the client supports diffPatch', async () => { + const updateEvent = await createFileChangeUpdate({ + type: 'fileChange', + id: 'file-change-patches', + changes: [ + {path: '/test/New.kt', kind: {type: 'add'}, diff: 'new line\n'}, + {path: '/test/Old.kt', kind: {type: 'delete'}, diff: 'old line\n'}, + { + path: '/test/Edit.kt', + kind: {type: 'update', move_path: null}, + diff: '@@ -1 +1 @@\n-old line\n+new line\n', + }, + ], + status: 'completed', + }, true); + + expect(updateEvent.sessionUpdate).toBe('tool_call'); + if (updateEvent.sessionUpdate !== 'tool_call') throw new Error('Expected a tool call'); + const contentBlocks = updateEvent.content ?? []; + expect(contentBlocks).toHaveLength(3); + for (const content of contentBlocks) { + expect(content).toMatchObject({ + type: 'diff', + oldText: null, + newText: '', + _meta: { + jetbrains: { + air: { + version: 1, + diffPatch: {version: 1, format: 'git_patch'}, + }, + }, + }, + }); + } + const patches = contentBlocks.map((block) => (block._meta as any).jetbrains.air.diffPatch.text); + expect(patches[0]).toContain('--- /dev/null'); + expect(patches[1]).toContain('+++ /dev/null'); + expect(patches[2]).toContain('@@ -1 +1 @@'); + }); + + describe('diff patch fallback', () => { + function onlyContent(event: Awaited>) { + if (event.sessionUpdate !== 'tool_call') throw new Error('Expected a tool call'); + expect(event.content).toHaveLength(1); + return event.content![0]!; + } + + it('sends the standard diff when the patch mode is not negotiated', async () => { + const content = onlyContent(await createFileChangeUpdate({ + type: 'fileChange', + id: 'legacy-delete', + changes: [{path: '/w/Old.kt', kind: {type: 'delete'}, diff: 'old line\n'}], + status: 'completed', + }, false)); + + expect(content).toEqual({ + type: 'diff', + oldText: 'old line\n', + newText: '', + path: '/w/Old.kt', + _meta: {kind: 'delete'}, + }); + }); + + it.each([ + {name: 'an empty added file', kind: {type: 'add' as const}, diff: '', expected: {oldText: null, newText: ''}}, + {name: 'an empty deleted file', kind: {type: 'delete' as const}, diff: '', expected: {oldText: '', newText: ''}}, + {name: 'a binary added file', kind: {type: 'add' as const}, diff: 'PNG\0data', expected: {oldText: null, newText: 'PNG\0data'}}, + ])('sends the standard diff for $name', async ({kind, diff, expected}) => { + const content = onlyContent(await createFileChangeUpdate({ + type: 'fileChange', + id: 'fallback', + changes: [{path: '/w/File', kind, diff}], + status: 'completed', + }, true)); + + expect(content).toMatchObject({type: 'diff', path: '/w/File', ...expected}); + expect(JSON.stringify(content)).not.toContain('diffPatch'); + }); + + it('sends the standard diff when the Codex hunks cannot form a valid patch', async () => { + mockFileContent('/w/Edit.kt', 'old\n'); + const content = onlyContent(await createFileChangeUpdate({ + type: 'fileChange', + id: 'fallback-update', + changes: [{ + path: '/w/Edit.kt', + kind: {type: 'update', move_path: null}, + diff: 'Index: /w/Edit.kt\n@@ -1 +1 @@\n-old\n+new\n', + }], + status: 'completed', + }, true)); + + expect(content).toEqual({ + type: 'diff', + oldText: 'old\n', + newText: 'new\n', + path: '/w/Edit.kt', + _meta: {kind: 'update'}, + }); + }); + + it('sends the standard diff for a pure rename', async () => { + mockFileContent('/w/New.kt', 'same\n'); + const content = onlyContent(await createFileChangeUpdate({ + type: 'fileChange', + id: 'pure-rename', + changes: [{path: '/w/Old.kt', kind: {type: 'update', move_path: '/w/New.kt'}, diff: ''}], + status: 'completed', + }, true)); + + expect(content).toMatchObject({type: 'diff', oldText: 'same\n', newText: 'same\n', path: '/w/New.kt'}); + }); + + it('uses the target path of a rename in the patch block', async () => { + const content = onlyContent(await createFileChangeUpdate({ + type: 'fileChange', + id: 'rename', + changes: [{ + path: '/w/Old.kt', + kind: {type: 'update', move_path: '/w/New.kt'}, + diff: '@@ -1 +1 @@\n-old\n+new\n\n\nMoved to: /w/New.kt', + }], + status: 'completed', + }, true)); + + expect(content.type === 'diff' && content.path).toBe('/w/New.kt'); + expect((content._meta as any).jetbrains.air.diffPatch.text).toContain('rename from w/Old.kt\nrename to w/New.kt\n'); + }); + + it('uses the target path when the moved file is already patched', async () => { + mockFileContent('/w/Old.kt', 'new\n'); + const content = onlyContent(await createFileChangeUpdate({ + type: 'fileChange', + id: 'already-patched-move', + changes: [{path: '/w/Old.kt', kind: {type: 'update', move_path: '/w/New.kt'}, diff: '@@ -1 +1 @@\n-old\n+new\n'}], + status: 'completed', + }, false)); + + expect(content).toMatchObject({oldText: 'old\n', newText: 'new\n', path: '/w/New.kt'}); + }); + }); }); diff --git a/src/__tests__/CodexACPAgent/initialize.test.ts b/src/__tests__/CodexACPAgent/initialize.test.ts index e6bdb8bb..537046b0 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", "diffPatch", "agentFileChangeReport", "nativeSubagentSessions", "asyncTasks", "recommendedValue"], }, }, }, diff --git a/src/__tests__/DiffStats.test.ts b/src/__tests__/DiffStats.test.ts deleted file mode 100644 index c553c95e..00000000 --- a/src/__tests__/DiffStats.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { parsePatch } from 'diff'; -import { describe, expect, it } from 'vitest'; -import { DiffStatsCalculator } from '../DiffStats'; - -describe('ACP diff statistics', () => { - const calculator = new DiffStatsCalculator(); - - it.each([ - ['', 0], - ['line', 1], - ['line\n', 1], - ['\n', 1], - ['\n\n', 2], - ['first\n\nlast\n', 3], - ['first\r\n\r\nlast\r\n', 3], - ['first\r\rlast\r', 3], - ['first\r\nsecond\rthird\n', 3], - ])('counts added and deleted lines in %j', (text, count) => { - expect(calculator.addedFile(text)).toEqual({ - version: 1, added: count, removed: 0, - }); - expect(calculator.deletedFile(text)).toEqual({ - version: 1, added: 0, removed: count, - }); - }); - - it.each([ - { - name: 'replacement with blank context', - patch: '@@ -1,3 +1,4 @@\n first\n \n-old\n+new\n+extra\n', - added: 2, removed: 1, - }, - { - name: 'deletion at EOF', - patch: '@@ -2 +1,0 @@\n-last\n', - added: 0, removed: 1, - }, - { - name: 'deletion of the entire file', - patch: '@@ -1,2 +0,0 @@\n-first\n-last\n', - added: 0, removed: 2, - }, - { - name: 'insertion after EOF', - patch: '@@ -1,0 +2,2 @@\n+second\n+third\n', - added: 2, removed: 0, - }, - { - name: 'multiple hunks with shifted coordinates', - patch: '@@ -2 +2,2 @@\n-two\n+TWO\n+inserted\n@@ -5 +6 @@\n-five\n+FIVE\n', - added: 3, removed: 2, - }, - { - name: 'missing EOF newline markers do not count as lines', - patch: '@@ -1 +1 @@\n-old\n\\ No newline at end of file\n+new\n\\ No newline at end of file\n', - added: 1, removed: 1, - }, - { - name: 'counts the patch operations for an EOF newline change', - patch: '@@ -1 +1 @@\n-same\n\\ No newline at end of file\n+same\n', - added: 1, removed: 1, - }, - { - name: 'a blank line differs from an empty file', - patch: '@@ -0,0 +1 @@\n+\n', - added: 1, removed: 0, - }, - { - name: 'CRLF patch lines', - patch: '@@ -1,2 +1,2 @@\r\n first\r\n-old\r\n+new\r\n', - added: 1, removed: 1, - }, - { - name: 'the patch counts are retained even when a minimal diff is smaller', - patch: '@@ -1,2 +1,2 @@\n-same\n-old\n+same\n+new\n', - added: 2, removed: 2, - }, - ])('$name', ({ patch, added, removed }) => { - expect(calculator.update(parsePatch(patch)[0]!)).toEqual({ version: 1, added, removed }); - }); - - it.each([ - { oldStart: -1 }, - { newStart: NaN }, - { newStart: 1.5 }, - { oldLines: 2 }, - { newLines: 0 }, - { lines: ['-old', '+new', '?garbage'] }, - { lines: ['\\ No newline at end of file', '-old', '+new'] }, - { lines: ['-old', '\\ invalid marker', '+new'] }, - ])('omits malformed hunk statistics: %j', (change) => { - const patch = parsePatch('@@ -1 +1 @@\n-old\n+new\n')[0]!; - patch.hunks[0] = { ...patch.hunks[0]!, ...change }; - expect(calculator.update(patch)).toBeNull(); - }); - - it('counts a patch at large coordinates without requiring file texts', () => { - const patch = parsePatch('@@ -1000000 +1000000 @@\n-old\n+new\n')[0]!; - expect(calculator.update(patch)).toEqual({ version: 1, added: 1, removed: 1 }); - }); - - it('omits overlapping hunks', () => { - const patch = parsePatch('@@ -1 +1 @@\n-old\n+new\n')[0]!; - patch.hunks.push({ ...patch.hunks[0]! }); - expect(calculator.update(patch)).toBeNull(); - }); - - it('omits binary patches and missing hunks', () => { - const patch = parsePatch('@@ -1 +1 @@\n-old\n+new\n')[0]!; - expect(calculator.update({ ...patch, hunks: [] })).toBeNull(); - patch.isBinary = true; - expect(calculator.update(patch)).toBeNull(); - }); -}); diff --git a/src/__tests__/GitPatch.test.ts b/src/__tests__/GitPatch.test.ts new file mode 100644 index 00000000..89acce96 --- /dev/null +++ b/src/__tests__/GitPatch.test.ts @@ -0,0 +1,96 @@ +import {describe, expect, it} from "vitest"; +import {parsePatch} from "diff"; +import { + createAddedFileGitPatch, + createDeletedFileGitPatch, + createUpdateGitPatch, + DIFF_PATCH_MAX_BYTES, +} from "../GitPatch"; + +describe("GitPatch", () => { + it("strips the leading slash of an absolute path in every header", () => { + expect(createUpdateGitPatch("/workspace/src/App.ts", "/workspace/src/App.ts", "@@ -1 +1 @@\n-old\n+new\n")).toBe( + "diff --git a/workspace/src/App.ts b/workspace/src/App.ts\n" + + "--- a/workspace/src/App.ts\n" + + "+++ b/workspace/src/App.ts\n" + + "@@ -1 +1 @@\n-old\n+new\n", + ); + }); + + it("adds rename headers and uses the target path", () => { + expect(createUpdateGitPatch("/w/Old.kt", "/w/New.kt", "@@ -1 +1 @@\n-old\n+new\n")).toBe( + "diff --git a/w/Old.kt b/w/New.kt\n" + + "rename from w/Old.kt\n" + + "rename to w/New.kt\n" + + "--- a/w/Old.kt\n" + + "+++ b/w/New.kt\n" + + "@@ -1 +1 @@\n-old\n+new\n", + ); + }); + + it("replaces the file headers that Codex supplied", () => { + const patch = createUpdateGitPatch( + "/w/App.ts", + "/w/App.ts", + "--- /w/App.ts\n+++ /w/App.ts\n@@ -1 +1 @@\n-old\n+new\n", + ); + + expect(patch).toBe( + "diff --git a/w/App.ts b/w/App.ts\n--- a/w/App.ts\n+++ b/w/App.ts\n@@ -1 +1 @@\n-old\n+new\n", + ); + }); + + it("quotes a path as Git does", () => { + const patch = createUpdateGitPatch("/w/a\"b.txt", "/w/a\"b.txt", "@@ -1 +1 @@\n-old\n+new\n"); + + expect(patch).toContain("diff --git \"a/w/a\\\"b.txt\" \"b/w/a\\\"b.txt\"\n"); + expect(patch).toContain("--- \"a/w/a\\\"b.txt\"\n"); + expect(createUpdateGitPatch("/w/a b.txt", "/w/a b.txt", "@@ -1 +1 @@\n-old\n+new\n")) + .toContain("--- a/w/a b.txt\t\n+++ b/w/a b.txt\t\n"); + }); + + it("keeps carriage returns in update hunks and whole-file patches", () => { + expect(createUpdateGitPatch("/w/a.txt", "/w/a.txt", "@@ -1 +1 @@\n-old\r\n+new\r\n")) + .toContain("@@ -1 +1 @@\n-old\r\n+new\r\n"); + expect(createAddedFileGitPatch("/w/a.txt", "one\r\ntwo\r\n")).toContain("@@ -0,0 +1,2 @@\n+one\r\n+two\r\n"); + }); + + it("builds a whole-file patch for an added file", () => { + expect(createAddedFileGitPatch("/w/New.kt", "one\ntwo\n")).toBe( + "diff --git a/w/New.kt b/w/New.kt\n" + + "new file mode 100644\n" + + "--- /dev/null\n" + + "+++ b/w/New.kt\n" + + "@@ -0,0 +1,2 @@\n+one\n+two\n", + ); + }); + + it("builds a whole-file patch for a deleted file without a final newline", () => { + const patch = createDeletedFileGitPatch("/w/Old.kt", "last"); + + expect(patch).toBe( + "diff --git a/w/Old.kt b/w/Old.kt\n" + + "deleted file mode 100644\n" + + "--- a/w/Old.kt\n" + + "+++ /dev/null\n" + + "@@ -1 +0,0 @@\n-last\n\\ No newline at end of file\n", + ); + expect(parsePatch(patch!)[0]!.hunks[0]!.lines).toEqual(["-last", "\\ No newline at end of file"]); + }); + + it("builds no patch for an empty, binary or huge file", () => { + expect(createAddedFileGitPatch("/w/empty", "")).toBeNull(); + expect(createDeletedFileGitPatch("/w/empty", "")).toBeNull(); + expect(createAddedFileGitPatch("/w/image.png", "PNG\0\x01\x02")).toBeNull(); + expect(createUpdateGitPatch("/w/b.bin", "/w/b.bin", "@@ -1 +1 @@\n-a\0\n+b\0\n")).toBeNull(); + expect(createAddedFileGitPatch("/w/huge.txt", "x".repeat(DIFF_PATCH_MAX_BYTES))).toBeNull(); + }); + + it("builds no patch for a malformed or empty update diff", () => { + expect(createUpdateGitPatch("/w/a", "/w/a", "")).toBeNull(); + expect(createUpdateGitPatch("/w/a", "/w/b", "")).toBeNull(); + expect(createUpdateGitPatch("/w/a", "/w/a", "@@ broken @@\n+x\n")).toBeNull(); + expect(createUpdateGitPatch("/w/a", "/w/a", "@@ -1 +1 @@\n-old\n+new\n+extra\n")).toBeNull(); + expect(createUpdateGitPatch("/w/a", "/w/a", "preamble\n@@ -1 +1 @@\n-old\n+new\n")).toBeNull(); + }); +}); From 2dcf33ba4cc7832064ab0f56691a595a4aa94d1f Mon Sep 17 00:00:00 2001 From: Nikita Ashikhmin Date: Wed, 23 Sep 2026 16:15:32 +0400 Subject: [PATCH 02/14] fix: accept only the Git final newline marker in a Codex hunk The hunk validator accepted every line that starts with a backslash. A hunk with such a line then went to the client as a valid patch. Now the validator accepts only the exact "\ No newline at end of file" line that Git writes. Any other backslash line makes the hunks invalid. The adapter then sends the standard ACP diff. --- docs/diff-patch-extension.md | 2 +- src/GitPatch.ts | 3 ++- .../CodexACPAgent/file-change-events.test.ts | 22 +++++++++++++++++++ src/__tests__/GitPatch.test.ts | 8 +++++++ 4 files changed, 33 insertions(+), 2 deletions(-) diff --git a/docs/diff-patch-extension.md b/docs/diff-patch-extension.md index 1f614613..97f41799 100644 --- a/docs/diff-patch-extension.md +++ b/docs/diff-patch-extension.md @@ -103,7 +103,7 @@ The adapter uses the fallback in these cases: - The content is binary. The adapter treats content as binary when its first 8000 characters contain a NUL character. - The patch text is larger than 1 MiB (`DIFF_PATCH_MAX_BYTES`). - A pure rename has no hunk. -- The update hunks from Codex are malformed. +- The update hunks from Codex are malformed. In a hunk, a line that starts with `\` is valid only as the exact `\ No newline at end of file` marker. For an update, the fallback reads the file and applies the Codex hunks. When the adapter cannot parse the hunks or apply them, it omits the block and logs the change. diff --git a/src/GitPatch.ts b/src/GitPatch.ts index 2b8a9763..1182ea7e 100644 --- a/src/GitPatch.ts +++ b/src/GitPatch.ts @@ -101,6 +101,7 @@ function hunkText(diff: string): string | null { newLines--; break; case "\\": + if (line !== NO_NEWLINE_MARKER) return null; break; default: return null; @@ -108,7 +109,7 @@ function hunkText(diff: string): string | null { index++; } if (oldLines !== 0 || newLines !== 0) return null; - while (hunks[index]?.startsWith("\\")) index++; + while (hunks[index] === NO_NEWLINE_MARKER) index++; } return `${hunks.join("\n")}\n`; } diff --git a/src/__tests__/CodexACPAgent/file-change-events.test.ts b/src/__tests__/CodexACPAgent/file-change-events.test.ts index 24ee7cde..a24e0279 100644 --- a/src/__tests__/CodexACPAgent/file-change-events.test.ts +++ b/src/__tests__/CodexACPAgent/file-change-events.test.ts @@ -642,6 +642,28 @@ Moved to: /test/project/NewFile.kt`, }); }); + it('sends the standard diff when a hunk has a marker line other than the final newline marker', async () => { + mockFileContent('/w/Edit.kt', 'old'); + const content = onlyContent(await createFileChangeUpdate({ + type: 'fileChange', + id: 'fallback-marker', + changes: [{ + path: '/w/Edit.kt', + kind: {type: 'update', move_path: null}, + diff: '@@ -1 +1 @@\n-old\n\\ injected\n+new\n', + }], + status: 'completed', + }, true)); + + expect(content).toEqual({ + type: 'diff', + oldText: 'old', + newText: 'new\n', + path: '/w/Edit.kt', + _meta: {kind: 'update'}, + }); + }); + it('sends the standard diff for a pure rename', async () => { mockFileContent('/w/New.kt', 'same\n'); const content = onlyContent(await createFileChangeUpdate({ diff --git a/src/__tests__/GitPatch.test.ts b/src/__tests__/GitPatch.test.ts index 89acce96..1b973526 100644 --- a/src/__tests__/GitPatch.test.ts +++ b/src/__tests__/GitPatch.test.ts @@ -93,4 +93,12 @@ describe("GitPatch", () => { expect(createUpdateGitPatch("/w/a", "/w/a", "@@ -1 +1 @@\n-old\n+new\n+extra\n")).toBeNull(); expect(createUpdateGitPatch("/w/a", "/w/a", "preamble\n@@ -1 +1 @@\n-old\n+new\n")).toBeNull(); }); + + it("accepts only the Git final newline marker after a hunk line", () => { + expect(createUpdateGitPatch("/w/a", "/w/a", "@@ -1 +1 @@\n-old\n\\ No newline at end of file\n+new\n\\ No newline at end of file\n")) + .toContain("-old\n\\ No newline at end of file\n+new\n\\ No newline at end of file\n"); + expect(createUpdateGitPatch("/w/a", "/w/a", "@@ -1 +1 @@\n-old\n\\ injected\n+new\n")).toBeNull(); + expect(createUpdateGitPatch("/w/a", "/w/a", "@@ -1 +1 @@\n-old\n+new\n\\\n")).toBeNull(); + expect(createUpdateGitPatch("/w/a", "/w/a", "@@ -1 +1 @@\n-old\n+new\n\\ No newline at end of file.\n")).toBeNull(); + }); }); From 003e86501529bddcfd90b7161ba31d7654c005a5 Mon Sep 17 00:00:00 2001 From: Nikita Ashikhmin Date: Wed, 23 Sep 2026 15:16:55 +0400 Subject: [PATCH 03/14] feat!: report tool calls through one contract, and fix the reports for every client Report tool calls through reporters and one renderer. The tool call mapping moves to src/tool-calls/. A ToolReporter per tool kind reads the Codex item once and returns tool facts. One AcpToolCallRenderer puts each fact into one ACP field. It reads the capability choices from one ClientCapabilities object. CodexToolCallMapper, TerminalOutputMode, PlanCapabilities and permissions/presentation.ts are removed. Only AIR gets the AIR shape. A client is AIR when it declares clientCapabilities._meta.jetbrains.air. A client that is not AIR, for example Zed or a plain ACP client, gets the fields that origin/main sent. ToolFacts.standard holds these fields where they differ. Fixes for every client: - Send only changed tool call fields. A new ToolCallReportingConnection wraps the ACP client connection and runs every session update through the ToolCallReports filter: live events, history replay, MCP startup, permission and elicitation updates, plan review and async tasks. The permission request tool call counts as a report. Appended output chunks are never compared. ACP defines no merge for the keys of _meta, so a client that is not AIR gets the whole _meta of each report. - Release the reported fields when a turn ends, when a native child session ends, and after a cancelled or failed permission request. Keep the small fields of a finished tool call, so a replayed tool call does not repeat its status. - Drop terminal and MCP output deltas that arrive after the tool call finished. - Send replayed command output once, and generated images once. - Make the MCP startup tool call ids unique. - Forward the result of a dynamic tool in content. - Keep the shown title, status and kind of a started tool call in its permission request. - Send empty locations when a fuzzy search finds no file. - Keep every notification of a pending subagent. The buffer merges adjacent text deltas and is bounded by bytes, not by count. It stores a copy of each notification. - Send terminal_output_delta chunks to a client that declares no terminal channel. Zed gets terminal_output for a command with a terminal. The AIR profile: - Move the AIR metadata keys to _meta.jetbrains.air: the message phase, the goal, the mode kind, the command action, the permission presentation and the compaction record. AIR is not released, so the adapter sends only the new keys. - One fact goes into one field. A command sends its output in terminal_output_delta, the raw stdin in _meta.terminal_input and its end in terminal_exit, without rawOutput. Read, search and list output is a result in content. - rawInputRendering: AIR gets no display copy of readable input. - planContentDelta: a streamed plan goes out as plan_update snapshots and _meta.jetbrains.air.contentDelta appends (CodexPlanStream). - The MCP result goes to rawOutput = {result, error}. AIR gets no MCP progress. Other clients get the trimmed progress text. - rawInput keeps the collaboration keys. Only spawnAgent gets _meta.jetbrains.air.subagent. - The plan review sends the plan text in rawInput.plan. --- src/AgentMode.ts | 28 +- src/AirExtension.ts | 25 + src/CodexAcpServer.ts | 211 ++-- src/CodexCommands.ts | 43 +- src/CodexElicitationHandler.ts | 24 +- src/CodexEventHandler.ts | 466 ++------- src/CodexPlanStream.ts | 151 +++ src/CodexToolCallMapper.ts | 974 ------------------ src/ContentChunks.ts | 6 +- src/ContextCompactionMeta.ts | 21 +- src/PlanCapabilities.ts | 7 - src/ResponseItemHistoryFallback.ts | 134 +-- src/TerminalOutputMode.ts | 45 - src/ToolCallReportingConnection.ts | 74 ++ src/ToolCallReports.ts | 178 ++++ .../CodexACPAgent/CodexAcpClient.test.ts | 17 +- .../agent-file-change-report.test.ts | 3 +- .../CodexACPAgent/approval-events.test.ts | 89 +- .../CodexACPAgent/auth-error-events.test.ts | 35 +- .../CodexACPAgent/auth-status.test.ts | 1 - .../CodexACPAgent/collab-agent-events.test.ts | 102 +- .../CodexACPAgent/command-output-once.test.ts | 254 +++++ .../data/agent-message-phases.json | 14 +- .../data/available-commands-build-in.json | 28 +- .../data/available-commands-skills.json | 28 +- .../data/command-list-files-with-path.json | 4 +- .../data/command-list-files-without-path.json | 4 +- .../data/command-read-file-with-path.json | 2 +- .../data/command-search-no-query-no-path.json | 4 +- .../data/command-search-with-path-only.json | 4 +- .../command-search-with-query-and-path.json | 4 +- .../data/command-search-with-query-only.json | 4 +- .../data/context-compaction-lifecycle.json | 17 +- .../data/dynamic-tool-completed.json | 11 +- .../data/dynamic-tool-in-progress.json | 4 +- ...elicitation-tool-approval-all-persist.json | 40 +- .../elicitation-tool-approval-no-persist.json | 34 +- ...licitation-tool-approval-session-only.json | 37 +- .../data/elicitation-url-accept.json | 31 +- .../data/file-change-add-multiple-files.json | 2 +- .../data/file-change-add-new-file.json | 2 +- .../data/file-change-add-raw-content.json | 2 +- .../data/file-change-delete-file.json | 2 +- .../data/file-change-delete-raw-content.json | 2 +- ...proval-review-completed-without-start.json | 22 +- .../data/guardian-approval-review-flow.json | 54 +- .../data/image-generation-completed-only.json | 7 +- .../data/image-generation-flow.json | 13 +- .../data/load-session-history.json | 78 +- ...ession-response-item-history-fallback.json | 54 +- .../data/mcp-tool-completed-with-logs.json | 27 - .../data/mcp-tool-repeated-progress.json | 41 - .../data/plan-completed-fallback.json | 7 +- .../data/plan-delta-fallback.json | 33 +- .../CodexACPAgent/data/plan-deltas.json | 33 +- .../response-item-history-tool-names.json | 4 +- .../data/session-compaction-legacy.json | 17 +- .../data/session-notices-replay.json | 7 +- .../data/terminal-command-completed.json | 5 + .../data/terminal-command-failed.json | 13 +- .../data/terminal-interaction-stdin.json | 4 +- ...al-output-parsed-command-legacy-delta.json | 33 +- .../data/thread-goal-cleared.json | 7 +- .../data/thread-goal-updated-multiline.json | 23 +- .../data/thread-goal-updated.json | 23 +- .../data/tool-call-command-names.json | 16 +- .../data/tool-call-completed-name.json | 9 +- .../data/tool-call-dynamic-names.json | 15 +- .../CodexACPAgent/data/view-image-flow.json | 2 +- .../data/web-search-action-titles.json | 4 - .../data/web-search-start-and-complete.json | 15 +- .../CodexACPAgent/elicitation-events.test.ts | 2 +- .../CodexACPAgent/file-change-events.test.ts | 9 +- .../fuzzy-file-search-events.test.ts | 2 - .../CodexACPAgent/initialize.test.ts | 26 +- .../CodexACPAgent/load-session.test.ts | 2 +- .../CodexACPAgent/plan-events.test.ts | 11 +- .../CodexACPAgent/plan-review-events.test.ts | 19 +- .../response-item-history-fallback.test.ts | 24 +- .../CodexACPAgent/session-compaction.test.ts | 8 +- .../terminal-output-events.test.ts | 15 +- .../CodexACPAgent/thread-goal-events.test.ts | 14 +- .../CodexACPAgent/turn-diff-events.test.ts | 1 - src/__tests__/CodexPlanStream.test.ts | 96 ++ .../PendingNotificationBuffer.test.ts | 56 + src/__tests__/TerminalOutputMode.test.ts | 34 - .../ToolCallReportingConnection.test.ts | 94 ++ src/__tests__/ToolCallReports.test.ts | 221 ++++ src/__tests__/acp-test-utils.ts | 6 +- .../tool-calls/data/tool-calls-air.json | 362 +++++++ .../tool-calls/data/tool-calls-zed.json | 360 +++++++ .../tool-calls/tool-call-contract.test.ts | 181 ++++ src/permissions/CodexApprovalHandler.ts | 37 +- src/permissions/lifecycle.ts | 13 + src/permissions/mcp.ts | 92 +- src/permissions/metadata.ts | 11 +- src/permissions/plan-review.ts | 38 - src/permissions/presentation.ts | 164 --- src/subagents/CodexSubagentEventRouter.ts | 57 +- src/subagents/PendingNotificationBuffer.ts | 71 ++ src/tool-calls/AcpToolCallRenderer.ts | 188 ++++ src/tool-calls/ClientCapabilities.ts | 99 ++ src/tool-calls/ToolFacts.ts | 96 ++ .../reporters/CollabAgentReporter.ts | 51 + src/tool-calls/reporters/CommandReporter.ts | 285 +++++ .../reporters/CompactionReporter.ts | 26 + .../reporters/DynamicToolReporter.ts | 49 + .../reporters/ElicitationReporter.ts | 53 + .../reporters/FileChangeReporter.ts | 203 ++++ .../reporters/FuzzySearchReporter.ts | 36 + src/tool-calls/reporters/GuardianReporter.ts | 152 +++ .../reporters/ImageGenerationReporter.ts | 105 ++ src/tool-calls/reporters/ImageViewReporter.ts | 21 + .../reporters/McpStartupReporter.ts | 32 + src/tool-calls/reporters/McpToolReporter.ts | 51 + .../reporters/PlanReviewReporter.ts | 56 + .../reporters/SandboxPermissionReporter.ts | 61 ++ .../reporters/SubagentActivityReporter.ts | 40 + src/tool-calls/reporters/ToolStatus.ts | 24 + src/tool-calls/reporters/WebSearchReporter.ts | 55 + 120 files changed, 4858 insertions(+), 2585 deletions(-) create mode 100644 src/CodexPlanStream.ts delete mode 100644 src/CodexToolCallMapper.ts delete mode 100644 src/PlanCapabilities.ts delete mode 100644 src/TerminalOutputMode.ts create mode 100644 src/ToolCallReportingConnection.ts create mode 100644 src/ToolCallReports.ts create mode 100644 src/__tests__/CodexACPAgent/command-output-once.test.ts create mode 100644 src/__tests__/CodexPlanStream.test.ts create mode 100644 src/__tests__/PendingNotificationBuffer.test.ts delete mode 100644 src/__tests__/TerminalOutputMode.test.ts create mode 100644 src/__tests__/ToolCallReportingConnection.test.ts create mode 100644 src/__tests__/ToolCallReports.test.ts create mode 100644 src/__tests__/tool-calls/data/tool-calls-air.json create mode 100644 src/__tests__/tool-calls/data/tool-calls-zed.json create mode 100644 src/__tests__/tool-calls/tool-call-contract.test.ts delete mode 100644 src/permissions/plan-review.ts delete mode 100644 src/permissions/presentation.ts create mode 100644 src/subagents/PendingNotificationBuffer.ts create mode 100644 src/tool-calls/AcpToolCallRenderer.ts create mode 100644 src/tool-calls/ClientCapabilities.ts create mode 100644 src/tool-calls/ToolFacts.ts create mode 100644 src/tool-calls/reporters/CollabAgentReporter.ts create mode 100644 src/tool-calls/reporters/CommandReporter.ts create mode 100644 src/tool-calls/reporters/CompactionReporter.ts create mode 100644 src/tool-calls/reporters/DynamicToolReporter.ts create mode 100644 src/tool-calls/reporters/ElicitationReporter.ts create mode 100644 src/tool-calls/reporters/FileChangeReporter.ts create mode 100644 src/tool-calls/reporters/FuzzySearchReporter.ts create mode 100644 src/tool-calls/reporters/GuardianReporter.ts create mode 100644 src/tool-calls/reporters/ImageGenerationReporter.ts create mode 100644 src/tool-calls/reporters/ImageViewReporter.ts create mode 100644 src/tool-calls/reporters/McpStartupReporter.ts create mode 100644 src/tool-calls/reporters/McpToolReporter.ts create mode 100644 src/tool-calls/reporters/PlanReviewReporter.ts create mode 100644 src/tool-calls/reporters/SandboxPermissionReporter.ts create mode 100644 src/tool-calls/reporters/SubagentActivityReporter.ts create mode 100644 src/tool-calls/reporters/ToolStatus.ts create mode 100644 src/tool-calls/reporters/WebSearchReporter.ts diff --git a/src/AgentMode.ts b/src/AgentMode.ts index c8c1ad03..b2041d0b 100644 --- a/src/AgentMode.ts +++ b/src/AgentMode.ts @@ -1,5 +1,6 @@ import type {ApprovalsReviewer, AskForApproval, SandboxMode, SandboxPolicy} from "./app-server/v2"; import type {SessionConfigOption, SessionMode, SessionModeState} from "@agentclientprotocol/sdk"; +import {AIR_KIND_KEY, airOnlyMeta} from "./AirExtension"; export const MODE_CONFIG_ID = "mode"; @@ -80,23 +81,25 @@ export class AgentMode { static DEFAULT_AGENT_MODE = AgentMode.Agent; - toSessionMode(): SessionMode { + /** Only AIR gets the mode kind, in `_meta.jetbrains.air.kind`. */ + toSessionMode(airClient: boolean): SessionMode { + const meta = airOnlyMeta(airClient, AIR_KIND_KEY, this.kind); return { id: this.id, name: this.name, description: this.description, - _meta: {kind: this.kind}, + ...(meta ? {_meta: meta} : {}), }; } - toSessionModeState(): SessionModeState { + toSessionModeState(airClient: boolean): SessionModeState { return { - availableModes: AgentMode.all().map(mode => mode.toSessionMode()), + availableModes: AgentMode.all().map(mode => mode.toSessionMode(airClient)), currentModeId: this.id }; } - toConfigOption(): SessionConfigOption { + toConfigOption(airClient: boolean): SessionConfigOption { return { id: MODE_CONFIG_ID, name: "Mode", @@ -104,12 +107,15 @@ export class AgentMode { category: "mode", type: "select", currentValue: this.id, - options: AgentMode.all().map(mode => ({ - value: mode.id, - name: mode.name, - description: mode.description, - _meta: {kind: mode.kind}, - })), + options: AgentMode.all().map(mode => { + const meta = airOnlyMeta(airClient, AIR_KIND_KEY, mode.kind); + return { + value: mode.id, + name: mode.name, + description: mode.description, + ...(meta ? {_meta: meta} : {}), + }; + }), }; } diff --git a/src/AirExtension.ts b/src/AirExtension.ts index a2b52cfc..17dac093 100644 --- a/src/AirExtension.ts +++ b/src/AirExtension.ts @@ -20,6 +20,12 @@ export const AIR_ASYNC_TASKS_KEY = "asyncTasks"; export const AIR_RECOMMENDED_CONFIG_VALUE_KEY = "recommendedValue"; export const AIR_ASYNC_TASKS_BACKGROUNDED_KEY = "backgrounded"; export const AIR_AGENT_FILE_CHANGE_REPORT_REQUEST_KEY = "agentFileChangeReportRequest"; +export const AIR_MESSAGE_PHASE_KEY = "phase"; +export const AIR_GOAL_KEY = "goal"; +export const AIR_KIND_KEY = "kind"; +export const AIR_COMMAND_ACTION_KEY = "commandAction"; +export const AIR_PERMISSION_KEY = "permission"; +export const AIR_CONTEXT_COMPACTION_KEY = "contextCompaction"; export const AIR_EXTENSION_VERSION = 1; /** Merge one AIR payload into metadata while preserving other object namespaces. */ @@ -44,6 +50,25 @@ export function withAirMeta( }; } +/** + * The metadata of a key that exists only for AIR. + * AIR gets `_meta.jetbrains.air.`. Every other client gets no metadata. + */ +export function airOnlyMeta(airClient: boolean, key: string, value: unknown): Record | undefined { + return airClient ? withAirMeta(undefined, key, value) : undefined; +} + +/** + * Tells whether the client is AIR. + * A client is AIR when `clientCapabilities._meta.jetbrains.air` is present. + */ +export function isAirClient(capabilities: ClientCapabilities | null | undefined): boolean { + const meta = asRecord(capabilities?._meta); + const jetbrains = asRecord(meta[JETBRAINS_META_KEY]); + const air = jetbrains[AIR_META_KEY]; + return air !== null && typeof air === "object" && !Array.isArray(air); +} + export function clientSupportsAirCapability( capabilities: ClientCapabilities | null | undefined, capability: string, diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index ca51a7ac..379f3532 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -3,11 +3,6 @@ import {RequestError, type SessionId, type SessionModeState} from "@agentclientp import {CodexEventHandler, type CompletedPlan} from "./CodexEventHandler"; import {CodexApprovalHandler} from "./permissions/CodexApprovalHandler"; import {PermissionLifecycleContext} from "./permissions/lifecycle"; -import { - planImplementationApproved, - planImplementationPermissionRequest, - planImplementationToolCallId, -} from "./permissions/plan-review"; import {CodexElicitationHandler} from "./CodexElicitationHandler"; import {type CodexAuthRequest, getCodexAuthMethods, isCodexAuthRequest} from "./CodexAuthMethod"; import {clientSupportsUrlElicitation} from "./ElicitationCapabilities"; @@ -61,6 +56,8 @@ import type {QuotaMeta} from "./QuotaMeta"; import {logger} from "./Logger"; import {sanitizeMcpServerName} from "./McpServerName"; import {createResponseItemHistoryFallbackUpdates} from "./ResponseItemHistoryFallback"; +import type {ToolCallReports} from "./ToolCallReports"; +import {ToolCallReportingConnection} from "./ToolCallReportingConnection"; import { AUTH_STATUS_META_KEY, AUTH_STATUS_UPDATE_METHOD, @@ -82,19 +79,24 @@ import { type SessionSteeringResponse, type SessionSteerRequest, } from "./AcpExtensions"; +import {AcpToolCallRenderer} from "./tool-calls/AcpToolCallRenderer"; import { - createCollabAgentToolCallUpdate, - createCommandExecutionCompleteUpdate, - createCommandExecutionUpdate, - createCompletedContextCompactionUpdate, - createDynamicToolCallUpdate, - createFileChangeUpdate, - createImageGenerationUpdate, - createImageViewUpdate, - createMcpToolCallUpdate, - createSubAgentActivityUpdate, - formatWebSearchTitle, -} from "./CodexToolCallMapper"; + AIR_PLAN_CONTENT_DELTA_KEY, + AIR_RAW_INPUT_RENDERING_KEY, + ClientCapabilities, +} from "./tool-calls/ClientCapabilities"; +import {CollabAgentReporter} from "./tool-calls/reporters/CollabAgentReporter"; +import {CommandReporter} from "./tool-calls/reporters/CommandReporter"; +import {CompactionReporter} from "./tool-calls/reporters/CompactionReporter"; +import {DynamicToolReporter} from "./tool-calls/reporters/DynamicToolReporter"; +import {FileChangeReporter} from "./tool-calls/reporters/FileChangeReporter"; +import {ImageGenerationReporter} from "./tool-calls/reporters/ImageGenerationReporter"; +import {ImageViewReporter} from "./tool-calls/reporters/ImageViewReporter"; +import {McpStartupReporter} from "./tool-calls/reporters/McpStartupReporter"; +import {McpToolReporter} from "./tool-calls/reporters/McpToolReporter"; +import {PlanReviewReporter} from "./tool-calls/reporters/PlanReviewReporter"; +import {SubagentActivityReporter} from "./tool-calls/reporters/SubagentActivityReporter"; +import {WebSearchReporter} from "./tool-calls/reporters/WebSearchReporter"; import { clientSupportsBooleanConfigOptions, createFastModeConfigOption, @@ -106,17 +108,11 @@ import { } from "./FastModeConfig"; import packageJson from "../package.json"; import {isJetBrains2026_1Client} from "./JBUtils"; -import { - clientSupportsTerminalOutputDelta, - resolveTerminalOutputMode, - type TerminalOutputMode, -} from "./TerminalOutputMode"; -import {clientSupportsPlanUpdates} from "./PlanCapabilities"; import {clientSupportsNotices} from "./SessionNotice"; import { createAgentTextMessageChunk, createAgentTextThoughtChunk, - createCodexMessagePhaseMeta, + createMessagePhaseMeta, createUserMessageChunk, } from "./ContentChunks"; import {sameThreadGoalSnapshot, type ThreadGoalSnapshot, toThreadGoalSnapshot,} from "./ThreadGoalSnapshot"; @@ -143,11 +139,13 @@ import { AIR_RECOMMENDED_CONFIG_VALUE_KEY, AIR_EXTENSION_CAPABILITIES_KEY, AIR_EXTENSION_VERSION, + AIR_GOAL_KEY, AIR_EXTENSION_VERSION_KEY, AIR_META_KEY, AIR_SESSION_FAILURE_KEY, clientSupportsAirCapability, JETBRAINS_META_KEY, + withAirMeta, } from "./AirExtension"; import {ASYNC_TASK_STOP_METHOD} from "./async-tasks/AsyncTaskExtension"; import {CodexBackgroundTerminalTasks} from "./async-tasks/CodexBackgroundTerminalTasks"; @@ -187,8 +185,8 @@ export interface SessionState { fastModeEnabled: boolean; currentModelSupportsFast: boolean; sessionMcpServers?: Array; - terminalOutputMode: TerminalOutputMode; - terminalOutputDeltaSupported: boolean; + /** The capability choices of the client for tool call and plan reports. */ + clientCapabilities: ClientCapabilities; currentGoal?: ThreadGoalSnapshot | null; goalRevision: number; sessionTitle: string | null; @@ -198,6 +196,7 @@ export interface SessionState { subagents: CodexSubagentEventRouter; asyncTasks: CodexBackgroundTerminalTasks; compactions: CodexSessionCompactions; + toolCallReports: ToolCallReports; } export type SessionFailureCategory = @@ -284,6 +283,7 @@ export interface CodexProcessState { export class CodexAcpServer { private codexAcpClient: CodexAcpClient; private readonly connection: AcpClientConnection; + private readonly reportingConnection: ToolCallReportingConnection; private readonly defaultAuthRequest: CodexAuthRequest | null; private readonly getExitCode: () => number | null; private readonly getRecentStderr: () => string; @@ -291,8 +291,8 @@ export class CodexAcpServer { private availableCommands: CodexCommands; private clientInfo: acp.Implementation | null; private clientCapabilities: acp.ClientCapabilities | null; - private terminalOutputMode: TerminalOutputMode; - private terminalOutputDeltaSupported: boolean; + /** The capability choices of the client for tool call and plan reports. */ + private capabilities: ClientCapabilities; private booleanConfigOptionsSupported: boolean; /** Last `authStatus` pushed to the client; used to suppress duplicates. */ private currentAuthStatus: AuthStatus | null; @@ -330,7 +330,8 @@ export class CodexAcpServer { this.sessionOpenGenerations = new Map(); this.goalControlGenerations = new Map(); this.permissionLifecycleContexts = new WeakMap(); - this.connection = connection; + this.reportingConnection = new ToolCallReportingConnection(connection); + this.connection = this.reportingConnection.asClientConnection(); this.codexAcpClient = codexAcpClient; this.defaultAuthRequest = defaultAuthRequest ?? null; this.codexProcessState = codexProcessState ?? null; @@ -340,8 +341,7 @@ export class CodexAcpServer { this.sessionFailureEpoch = randomUUID(); this.clientInfo = null; this.clientCapabilities = null; - this.terminalOutputMode = "terminal_output_delta"; - this.terminalOutputDeltaSupported = false; + this.capabilities = ClientCapabilities.DEFAULT; this.booleanConfigOptionsSupported = false; this.currentAuthStatus = null; this.availableCommands = this.createAvailableCommands(codexAcpClient); @@ -364,11 +364,16 @@ export class CodexAcpServer { this.clientInfo = _params.clientInfo ?? null; this.clientCapabilities = _params.clientCapabilities ?? null; this.initializeRequest = _params; - this.terminalOutputMode = resolveTerminalOutputMode(_params.clientCapabilities); - this.terminalOutputDeltaSupported = clientSupportsTerminalOutputDelta(_params.clientCapabilities); + this.capabilities = ClientCapabilities.from(_params.clientCapabilities); + this.reportingConnection.reports.compareMeta = this.capabilities.airClient; this.booleanConfigOptionsSupported = clientSupportsBooleanConfigOptions(_params.clientCapabilities); await this.runWithProcessCheck(() => this.codexAcpClient.initialize(_params)); this.publishFirstAuthStatusAfterResponse(); + const goalCapability = { + version: GOAL_EXTENSION_VERSION, + controlMethod: GOAL_CONTROL_METHOD, + actions: [...GOAL_CONTROL_ACTIONS], + }; const sessionCapabilities: SubagentAwareSessionCapabilities = { resume: { }, list: { }, @@ -412,24 +417,25 @@ export class CodexAcpServer { steering: { supported: true, }, - goal: { - version: GOAL_EXTENSION_VERSION, - controlMethod: GOAL_CONTROL_METHOD, - actions: [...GOAL_CONTROL_ACTIONS], - }, - [JETBRAINS_META_KEY]: { - [AIR_META_KEY]: { - [AIR_EXTENSION_VERSION_KEY]: AIR_EXTENSION_VERSION, - [AIR_EXTENSION_CAPABILITIES_KEY]: [ - AIR_SESSION_FAILURE_KEY, - AIR_DIFF_PATCH_KEY, - AIR_AGENT_FILE_CHANGE_REPORT_KEY, - AIR_NATIVE_SUBAGENT_SESSIONS_KEY, - AIR_ASYNC_TASKS_KEY, - AIR_RECOMMENDED_CONFIG_VALUE_KEY, - ], + // Only AIR gets the AIR extension, see `docs/air-extensions.md`. + ...(this.capabilities.airClient ? { + [JETBRAINS_META_KEY]: { + [AIR_META_KEY]: { + [AIR_EXTENSION_VERSION_KEY]: AIR_EXTENSION_VERSION, + [AIR_GOAL_KEY]: goalCapability, + [AIR_EXTENSION_CAPABILITIES_KEY]: [ + AIR_SESSION_FAILURE_KEY, + AIR_DIFF_PATCH_KEY, + AIR_AGENT_FILE_CHANGE_REPORT_KEY, + AIR_NATIVE_SUBAGENT_SESSIONS_KEY, + AIR_ASYNC_TASKS_KEY, + AIR_RECOMMENDED_CONFIG_VALUE_KEY, + AIR_RAW_INPUT_RENDERING_KEY, + AIR_PLAN_CONTENT_DELTA_KEY, + ], + }, }, - }, + } : {}), }, }; } @@ -714,8 +720,7 @@ export class CodexAcpServer { fastModeEnabled: sessionMetadata.currentServiceTier === "fast", currentModelSupportsFast: currentModelSupportsFast, sessionMcpServers: sessionMcpServers, - terminalOutputMode: this.terminalOutputMode, - terminalOutputDeltaSupported: this.terminalOutputDeltaSupported, + clientCapabilities: this.capabilities, goalRevision: 0, sessionTitle: null, sessionTitleSource: operation === "resume" ? "unknown" : "unset", @@ -723,9 +728,11 @@ export class CodexAcpServer { sessionId, clientSupportsSubagents(this.clientCapabilities), new ACPSessionConnection(this.connection, sessionId), + childSessionId => this.reportingConnection.reports.releaseOpen(childSessionId), ), asyncTasks: this.createAsyncTasks(sessionId), compactions: new CodexSessionCompactions(), + toolCallReports: this.reportingConnection.reports, }; sessionState.titleGen = new TitleGenerator( this.codexAcpClient.appServerClient, @@ -753,7 +760,8 @@ export class CodexAcpServer { this.publishAsyncTasksAsync(sessionState, sessionGeneration); } const sessionModelState: LegacySessionModelState = this.createModelState(models, currentModelId); - const sessionModeState: SessionModeState = sessionState.agentMode.toSessionModeState(); + const sessionModeState: SessionModeState = + sessionState.agentMode.toSessionModeState(sessionState.clientCapabilities.airClient); return [sessionId, sessionModelState, sessionModeState]; } @@ -1776,7 +1784,7 @@ export class CodexAcpServer { ? sessionState.availableModels.find(model => model.isDefault)?.id : undefined; const configOptions = [ - sessionState.agentMode.toConfigOption(), + sessionState.agentMode.toConfigOption(sessionState.clientCapabilities.airClient), createCollaborationModeConfigOption(sessionState.collaborationMode), createModelConfigOption(sessionState.availableModels, currentModelId.model, recommendedModelId), ]; @@ -1880,12 +1888,12 @@ export class CodexAcpServer { return; } sessionState.currentGoal = snapshot; + // Only AIR gets the goal. The update carries nothing else, so another client gets no update. + if (!sessionState.clientCapabilities.airClient) return; const session = new ACPSessionConnection(this.connection, sessionState.sessionId); await session.update({ sessionUpdate: "session_info_update", - _meta: { - goal: snapshot, - }, + _meta: withAirMeta(undefined, AIR_GOAL_KEY, snapshot), }); } @@ -1980,8 +1988,7 @@ export class CodexAcpServer { fastModeEnabled: sessionMetadata.currentServiceTier === "fast", currentModelSupportsFast: currentModelSupportsFast, sessionMcpServers: sessionMcpServers, - terminalOutputMode: this.terminalOutputMode, - terminalOutputDeltaSupported: this.terminalOutputDeltaSupported, + clientCapabilities: this.capabilities, goalRevision: 0, sessionTitle: null, sessionTitleSource: "unset", @@ -1989,9 +1996,11 @@ export class CodexAcpServer { sessionId, clientSupportsSubagents(this.clientCapabilities), new ACPSessionConnection(this.connection, sessionId), + childSessionId => this.reportingConnection.reports.releaseOpen(childSessionId), ), asyncTasks: this.createAsyncTasks(sessionId), compactions: new CodexSessionCompactions(), + toolCallReports: this.reportingConnection.reports, }; sessionState.titleGen = new TitleGenerator( this.codexAcpClient.appServerClient, @@ -2013,7 +2022,8 @@ export class CodexAcpServer { await this.publishAvailableCommands(sessionState, requestedSessionGeneration); await this.publishCurrentGoalBestEffort(sessionState, requestedSessionGeneration, true); const sessionModelState: LegacySessionModelState = this.createModelState(models, currentModelId); - const sessionModeState: SessionModeState = sessionState.agentMode.toSessionModeState(); + const sessionModeState: SessionModeState = + sessionState.agentMode.toSessionModeState(sessionState.clientCapabilities.airClient); return { sessionId: sessionId, @@ -2039,7 +2049,7 @@ export class CodexAcpServer { } const responseItemFallbackUpdates = await createResponseItemHistoryFallbackUpdates( thread, - sessionState.terminalOutputMode, + sessionState.clientCapabilities, ); const threadUpdates: UpdateSessionEvent[] = []; @@ -2271,6 +2281,7 @@ export class CodexAcpServer { } private async createHistoryUpdates(item: ThreadItem, sessionState: SessionState): Promise { + const renderer = new AcpToolCallRenderer(sessionState.clientCapabilities); switch (item.type) { case "userMessage": return this.createUserMessageUpdates(item); @@ -2279,9 +2290,9 @@ export class CodexAcpServer { case "sleep": return []; case "subAgentActivity": - return [createSubAgentActivityUpdate(item, "completed", "tool_call")]; + return [renderer.render(SubagentActivityReporter.activity(item, "completed", "start"))]; case "agentMessage": { - const meta = createCodexMessagePhaseMeta(item.phase); + const meta = createMessagePhaseMeta(item.phase, sessionState.clientCapabilities.airClient); return [{ sessionUpdate: "agent_message_chunk", messageId: item.id, @@ -2292,30 +2303,21 @@ export class CodexAcpServer { case "reasoning": return this.createReasoningUpdates(item); case "fileChange": - return [await createFileChangeUpdate( - item, - clientSupportsAirCapability(this.clientCapabilities, AIR_DIFF_PATCH_KEY), - )]; - case "commandExecution": { - const updates = [await createCommandExecutionUpdate(item)]; - const completeUpdate = createCommandExecutionCompleteUpdate(item, sessionState.terminalOutputMode); - if (completeUpdate) { - updates.push(completeUpdate); - } - return updates; - } + return [renderer.render(await FileChangeReporter.started(item, renderer.capabilities.air.diffPatch))]; + case "commandExecution": + return CommandReporter.history(item).map(facts => renderer.render(facts)); case "mcpToolCall": - return [await createMcpToolCallUpdate(item)]; + return [renderer.render(McpToolReporter.started(item))]; case "dynamicToolCall": - return [await createDynamicToolCallUpdate(item)]; + return [renderer.render(DynamicToolReporter.started(item))]; case "collabAgentToolCall": - return [createCollabAgentToolCallUpdate(item)]; + return [renderer.render(CollabAgentReporter.started(item))]; case "webSearch": - return [this.createWebSearchUpdate(item)]; + return [renderer.render(WebSearchReporter.history(item))]; case "imageView": - return [createImageViewUpdate(item)]; + return [renderer.render(ImageViewReporter.viewed(item))]; case "imageGeneration": - return [createImageGenerationUpdate(item)]; + return [renderer.render(ImageGenerationReporter.whole(item))]; case "enteredReviewMode": return [this.createReviewModeUpdate(item, true)]; case "exitedReviewMode": @@ -2323,7 +2325,7 @@ export class CodexAcpServer { case "contextCompaction": return [clientSupportsCompaction(this.clientCapabilities) ? createCompactionUpdate(item.id, "completed") - : createCompletedContextCompactionUpdate(item)]; + : renderer.render(CompactionReporter.history(item))]; case "plan": return item.text.length > 0 ? [this.createPlanHistoryUpdate(item)] : []; } @@ -2347,22 +2349,6 @@ export class CodexAcpServer { return parts.map((text) => createAgentTextThoughtChunk(text, messageId)); } - private createWebSearchUpdate( - item: ThreadItem & { type: "webSearch" } - ): UpdateSessionEvent { - return { - sessionUpdate: "tool_call", - toolCallId: item.id, - kind: "search", - title: formatWebSearchTitle(item), - status: "completed", - rawInput: { - query: item.query, - action: item.action, - }, - }; - } - private createReviewModeUpdate( item: ThreadItem & { type: "enteredReviewMode" | "exitedReviewMode" }, entered: boolean @@ -2379,7 +2365,7 @@ export class CodexAcpServer { private createPlanHistoryUpdate( item: ThreadItem & { type: "plan" } ): UpdateSessionEvent { - if (clientSupportsPlanUpdates(this.clientCapabilities)) { + if (this.capabilities.planUpdates) { return { sessionUpdate: "plan_update", plan: { @@ -2392,7 +2378,7 @@ export class CodexAcpServer { return createAgentTextMessageChunk( item.text, item.id, - createCodexMessagePhaseMeta("final_answer"), + createMessagePhaseMeta("final_answer", this.capabilities.airClient), ); } @@ -2535,14 +2521,15 @@ export class CodexAcpServer { } } - for (const update of CodexEventHandler.createMcpStartupUpdates({ + const renderer = new AcpToolCallRenderer(this.capabilities); + for (const facts of McpStartupReporter.failures({ ...filteredStartup, ready: readyAfterOauth, failed: failuresAfterOauth, })) { await this.connection.notify(acp.methods.client.session.update, { sessionId, - update, + update: renderer.render(facts), }); } } @@ -2860,7 +2847,6 @@ export class CodexAcpServer { const promptEventHandler = new CodexEventHandler( this.connection, sessionState, - clientSupportsPlanUpdates(this.clientCapabilities), clientSupportsTypedSessionFailures(this.clientCapabilities), this.sessionFailureEpoch, sessionState.subagents, @@ -2868,21 +2854,23 @@ export class CodexAcpServer { agentFileChangeReportRequest !== null, clientSupportsCompaction(this.clientCapabilities), clientSupportsNotices(this.clientCapabilities), - clientSupportsAirCapability(this.clientCapabilities, AIR_DIFF_PATCH_KEY), ); eventHandler = promptEventHandler; const permissionLifecycle = this.permissionLifecycleContext(sessionState); const permissionContext = permissionLifecycle.beginPrompt(); + const toolCallRenderer = new AcpToolCallRenderer(this.capabilities); const approvalHandler = new CodexApprovalHandler( this.connection, permissionContext, activePrompt.signal, + toolCallRenderer, ); const elicitationHandler = new CodexElicitationHandler( this.connection, permissionContext, this.clientCapabilities, activePrompt.signal, + toolCallRenderer, ); const observeInteraction = async (event: ServerNotification): Promise => { permissionContext.handleNotification(event); @@ -3307,24 +3295,17 @@ export class CodexAcpServer { plan: CompletedPlan, cancellationSignal: AbortSignal, ): Promise { - const toolCallId = planImplementationToolCallId(plan); + const renderer = new AcpToolCallRenderer(sessionState.clientCapabilities); try { const response = await this.connection.request( acp.methods.client.session.requestPermission, - planImplementationPermissionRequest(sessionState.sessionId, plan), + PlanReviewReporter.permissionRequest(sessionState.sessionId, plan, renderer), {cancellationSignal}, ); - const approved = planImplementationApproved(response); + const approved = PlanReviewReporter.approved(response); await this.connection.notify(acp.methods.client.session.update, { sessionId: sessionState.sessionId, - update: { - sessionUpdate: "tool_call_update", - toolCallId, - status: "completed", - rawOutput: approved - ? "User approved the plan." - : "User kept the session in plan mode.", - }, + update: renderer.render(PlanReviewReporter.decided(plan, approved)), }); return approved; } catch (error) { diff --git a/src/CodexCommands.ts b/src/CodexCommands.ts index c99e338a..daeaa0e6 100644 --- a/src/CodexCommands.ts +++ b/src/CodexCommands.ts @@ -1,6 +1,7 @@ import type * as acp from "@agentclientprotocol/sdk"; import type {AvailableCommand} from "@agentclientprotocol/sdk"; import {ACPSessionConnection, type AcpClientConnection} from "./ACPSessionConnection"; +import {AIR_COMMAND_ACTION_KEY, airOnlyMeta} from "./AirExtension"; import type {CodexAcpClient} from "./CodexAcpClient"; import type {RateLimitSnapshot, ReviewTarget, SkillsListEntry, SkillsListParams, TurnCompletedNotification} from "./app-server/v2"; import type {SessionState} from "./CodexAcpServer"; @@ -60,7 +61,10 @@ export class CodexCommands { return; } const skillsResponse = await this.runWithProcessCheck(() => this.codexAcpClient.listSkills(this.createSkillsListParams(sessionState))); - const availableCommands = this.buildAvailableCommands(skillsResponse?.data ?? []); + const availableCommands = this.buildAvailableCommands( + skillsResponse?.data ?? [], + sessionState.clientCapabilities.airClient, + ); if (availableCommands.length === 0 || !shouldPublish()) { return; } @@ -83,10 +87,10 @@ export class CodexCommands { }; } - private buildAvailableCommands(skillsEntries: SkillsListEntry[]): AvailableCommand[] { + private buildAvailableCommands(skillsEntries: SkillsListEntry[], airClient: boolean): AvailableCommand[] { const commands = new Map(); - for (const builtin of this.getBuiltinCommands()) { + for (const builtin of this.getBuiltinCommands(airClient)) { commands.set(builtin.name, builtin); } @@ -107,22 +111,25 @@ export class CodexCommands { /** * See the original cli commands documentation here: https://developers.openai.com/codex/cli/slash-commands/ + * Only AIR gets a command action, in `_meta.jetbrains.air.commandAction`. */ - private getBuiltinCommands(): AvailableCommand[] { + private getBuiltinCommands(airClient: boolean): AvailableCommand[] { + const commandAction = (action: Record) => { + const meta = airOnlyMeta(airClient, AIR_COMMAND_ACTION_KEY, action); + return meta ? {_meta: meta} : {}; + }; return [ { name: "plan", description: "Turn plan mode on.", input: null, - _meta: { - commandAction: { - kind: "setConfigOption", - configId: COLLABORATION_MODE_CONFIG_ID, - value: PLAN_COLLABORATION_MODE, - resetValue: DEFAULT_COLLABORATION_MODE, - presentation: "state", - }, - }, + ...commandAction({ + kind: "setConfigOption", + configId: COLLABORATION_MODE_CONFIG_ID, + value: PLAN_COLLABORATION_MODE, + resetValue: DEFAULT_COLLABORATION_MODE, + presentation: "state", + }), }, { name: "mcp", @@ -163,12 +170,10 @@ export class CodexCommands { name: "goal", description: "Set a goal to keep pursuing.", input: { hint: "[|clear|pause|resume]" }, - _meta: { - commandAction: { - kind: "prefixPrompt", - presentation: "state", - }, - }, + ...commandAction({ + kind: "prefixPrompt", + presentation: "state", + }), }, { name: "rename", diff --git a/src/CodexElicitationHandler.ts b/src/CodexElicitationHandler.ts index 6e7236b8..c22003c1 100644 --- a/src/CodexElicitationHandler.ts +++ b/src/CodexElicitationHandler.ts @@ -23,6 +23,9 @@ import { type McpElicitationContext, } from "./permissions/mcp"; import type {PermissionPromptContext} from "./permissions/lifecycle"; +import {AcpToolCallRenderer} from "./tool-calls/AcpToolCallRenderer"; +import {ClientCapabilities} from "./tool-calls/ClientCapabilities"; +import {ElicitationReporter} from "./tool-calls/reporters/ElicitationReporter"; import {isRecord, normalizeJsonObject, normalizeJsonValue, recordOrNull} from "./permissions/json"; type AcpBackedMcpElicitationParams = Extract< McpServerElicitationRequestParams, @@ -137,6 +140,7 @@ function userInputResponseValue( export class CodexElicitationHandler implements ElicitationHandler { private readonly connection: AcpClientConnection; + private readonly renderer: AcpToolCallRenderer; private readonly permissionContext: PermissionPromptContext; private readonly clientCapabilities: acp.ClientCapabilities | null; private readonly cancellationSignal: AbortSignal | undefined; @@ -160,8 +164,10 @@ export class CodexElicitationHandler implements ElicitationHandler { connection: AcpClientConnection, permissionContext: PermissionPromptContext, clientCapabilities: acp.ClientCapabilities | null = null, - cancellationSignal?: AbortSignal + cancellationSignal?: AbortSignal, + renderer: AcpToolCallRenderer = new AcpToolCallRenderer(ClientCapabilities.from(clientCapabilities)), ) { + this.renderer = renderer; this.connection = connection; this.permissionContext = permissionContext; this.clientCapabilities = clientCapabilities; @@ -205,6 +211,7 @@ export class CodexElicitationHandler implements ElicitationHandler { params, context, () => this.permissionContext.nextStandaloneMcpToolCallId(params.serverName), + this.renderer, ); const response = await this.connection.request( acp.methods.client.session.requestPermission, @@ -220,21 +227,16 @@ export class CodexElicitationHandler implements ElicitationHandler { if (result.action === "accept") { await this.connection.notify(acp.methods.client.session.update, { sessionId: params.threadId, - update: { sessionUpdate: "tool_call_update", toolCallId: correlatedCallId, status: "in_progress" }, + update: this.renderer.render(ElicitationReporter.accepted(correlatedCallId)), }); } } else { try { await this.connection.notify(acp.methods.client.session.update, { sessionId: params.threadId, - update: { - sessionUpdate: "tool_call_update", - toolCallId: request.toolCall.toolCallId, - status: "completed", - title: request.toolCall.title, - content: request.toolCall.content, - rawOutput: { action: result.action }, - }, + update: this.renderer.render( + ElicitationReporter.answered(request.toolCall.toolCallId, result.action), + ), }); } catch (error) { logger.error("Failed to finalize standalone MCP elicitation tool call", error); @@ -555,7 +557,7 @@ export class CodexElicitationHandler implements ElicitationHandler { } await this.connection.notify(acp.methods.client.session.update, { sessionId, - update: { sessionUpdate: "tool_call_update", toolCallId: context.correlatedCallId, status: "in_progress" }, + update: this.renderer.render(ElicitationReporter.accepted(context.correlatedCallId)), }); } diff --git a/src/CodexEventHandler.ts b/src/CodexEventHandler.ts index cb776f10..e613e73f 100644 --- a/src/CodexEventHandler.ts +++ b/src/CodexEventHandler.ts @@ -1,6 +1,4 @@ import type { - FuzzyFileSearchSessionCompletedNotification, - FuzzyFileSearchSessionUpdatedNotification, ServerNotification } from "./app-server"; import type { @@ -16,21 +14,16 @@ import type { AccountUpdatedNotification, AgentMessageDeltaNotification, CodexErrorInfo, - CommandExecutionOutputDeltaNotification, ConfigWarningNotification, DeprecationNoticeNotification, ErrorNotification, - ItemGuardianApprovalReviewCompletedNotification, - ItemGuardianApprovalReviewStartedNotification, ItemCompletedNotification, ItemStartedNotification, ThreadItem, ModelReroutedNotification, - PlanDeltaNotification, ReasoningSummaryPartAddedNotification, ReasoningSummaryTextDeltaNotification, ReasoningTextDeltaNotification, - TerminalInteractionNotification, ThreadGoalClearedNotification, ThreadGoalUpdatedNotification, ThreadTokenUsageUpdatedNotification, @@ -38,35 +31,23 @@ import type { TurnPlanUpdatedNotification, WarningNotification } from "./app-server/v2"; -import type { McpStartupCompleteEvent } from "./app-server/McpStartupCompleteEvent"; import {toTokenCount} from "./TokenCount"; -import { - commandExecutionUsesTerminalOutput, - createCommandExecutionUpdate, - createContextCompactionCompleteUpdate, - createContextCompactionStartUpdate, - createDynamicToolCallUpdate, - createFileChangeUpdate, - createGuardianApprovalReviewToolCall, - createGuardianApprovalReviewToolCallUpdate, - createImageGenerationCompleteUpdate, - createImageGenerationStartUpdate, - createImageGenerationUpdate, - createImageViewUpdate, - createMcpRawInput, - createMcpRawOutput, - createFuzzyFileSearchComplete, - createFuzzyFileSearchStartOrUpdate, - createMcpToolCallUpdate, - createWebSearchCompleteUpdate, - createWebSearchStartUpdate, - fuzzyFileSearchToolCallId, -} from "./CodexToolCallMapper"; import { stripShellPrefix } from "./CommandUtils"; -import {commandToolName, functionToolName} from "./ToolCallName"; -import {createTerminalOutputMeta, type TerminalOutputMode} from "./TerminalOutputMode"; +import {AcpToolCallRenderer} from "./tool-calls/AcpToolCallRenderer"; +import type {ToolFacts} from "./tool-calls/ToolFacts"; +import {CommandReporter} from "./tool-calls/reporters/CommandReporter"; +import {CompactionReporter} from "./tool-calls/reporters/CompactionReporter"; +import {DynamicToolReporter} from "./tool-calls/reporters/DynamicToolReporter"; +import {FileChangeReporter} from "./tool-calls/reporters/FileChangeReporter"; +import {FuzzySearchReporter} from "./tool-calls/reporters/FuzzySearchReporter"; +import {GuardianReporter} from "./tool-calls/reporters/GuardianReporter"; +import {ImageGenerationReporter} from "./tool-calls/reporters/ImageGenerationReporter"; +import {ImageViewReporter} from "./tool-calls/reporters/ImageViewReporter"; +import {McpToolReporter} from "./tool-calls/reporters/McpToolReporter"; +import {WebSearchReporter} from "./tool-calls/reporters/WebSearchReporter"; +import {CodexPlanStream} from "./CodexPlanStream"; import { - createCodexMessagePhaseMeta, + createMessagePhaseMeta, createAgentTextMessageChunk, createAgentTextThoughtChunk, } from "./ContentChunks"; @@ -76,9 +57,11 @@ import {randomUUID} from "node:crypto"; import { AIR_EXTENSION_VERSION, AIR_EXTENSION_VERSION_KEY, + AIR_GOAL_KEY, AIR_META_KEY, AIR_SESSION_FAILURE_KEY, JETBRAINS_META_KEY, + withAirMeta, } from "./AirExtension"; import {CodexSubagentEventRouter} from "./subagents/CodexSubagentEventRouter"; import type {SubagentState} from "./subagents/AcpSubagents"; @@ -199,10 +182,7 @@ const STRUCTURED_CODEX_ERROR_CATEGORIES = { export class CodexEventHandler { - private static readonly PLAN_UPDATE_INTERVAL_MS = 150; - private readonly sessionState: SessionState; - private readonly supportsPlanUpdates: boolean; private readonly supportsTypedSessionFailures: boolean; private readonly sessionFailureEpoch: string; private readonly pendingErrors: ErrorNotification[] = []; @@ -214,20 +194,16 @@ export class CodexEventHandler { private nextNoticeId = 1; private failure: RequestError | null = null; private completedPlan: CompletedPlan | null = null; - private readonly activeFuzzyFileSearchSessions = new Set(); - private readonly activeGuardianApprovalReviews = new Set(); private readonly activeImageGenerationItems = new Set(); private readonly emittedImageViewItems = new Set(); - private readonly planDeltaTextByItemId = new Map(); - private readonly pendingPlanItemIds = new Set(); - private readonly lastEmittedPlanTextByItemId = new Map(); private readonly session: ACPSessionConnection; - private planUpdateTimer: ReturnType | null = null; - private planUpdateChain: Promise = Promise.resolve(); + private readonly renderer: AcpToolCallRenderer; + private readonly commands = new CommandReporter(); + private readonly fuzzySearches = new FuzzySearchReporter(); + private readonly guardianReviews = new GuardianReporter(); + private readonly plans: CodexPlanStream; private disposed = false; private readonly seenReasoningDeltaItemIds = new Set(); - private readonly terminalCommandIds = new Set(); - private readonly commandOutputIds = new Set(); private readonly agentMessagePhases = new Map(); private readonly turnDiffs = new Map(); private readonly oversizedTurnDiffs = new Set(); @@ -239,7 +215,6 @@ export class CodexEventHandler { constructor( connection: AcpClientConnection, sessionState: SessionState, - supportsPlanUpdates = false, supportsTypedSessionFailures = false, sessionFailureEpoch: string = randomUUID(), subagents: CodexSubagentEventRouter = new CodexSubagentEventRouter( @@ -251,14 +226,14 @@ export class CodexEventHandler { collectTurnDiffs = false, private readonly supportsCompaction = false, private readonly supportsNotices = false, - private readonly supportsDiffPatch = false, ) { this.onAccountUpdated = onAccountUpdated; this.sessionState = sessionState; - this.supportsPlanUpdates = supportsPlanUpdates; this.supportsTypedSessionFailures = supportsTypedSessionFailures; this.sessionFailureEpoch = sessionFailureEpoch; this.session = new ACPSessionConnection(connection, sessionState.sessionId); + this.renderer = new AcpToolCallRenderer(sessionState.clientCapabilities); + this.plans = new CodexPlanStream(this.session, sessionState.clientCapabilities); this.subagents = subagents; this.collectTurnDiffs = collectTurnDiffs; if (sessionState.sessionFailure !== undefined) { @@ -479,23 +454,12 @@ export class CodexEventHandler { } async flushPendingPlanUpdates(): Promise { - this.cancelPlanUpdateTimer(); - do { - const itemIds = [...this.pendingPlanItemIds]; - this.pendingPlanItemIds.clear(); - await Promise.all(itemIds.map(itemId => { - const text = this.planDeltaTextByItemId.get(itemId) ?? ""; - return text.length > 0 - ? this.enqueuePlanSnapshot(itemId, text) - : Promise.resolve(); - })); - await this.planUpdateChain; - } while (this.pendingPlanItemIds.size > 0); + await this.plans.flush(); } async dispose(): Promise { if (this.disposed) return; - await this.flushPendingPlanUpdates(); + await this.plans.dispose(); if (this.pendingErrors.length > 0) { logger.log("Discarding app-server errors that arrived before a turn started", { sessionId: this.sessionState.sessionId, @@ -505,10 +469,6 @@ export class CodexEventHandler { this.pendingErrors.splice(0); } this.disposed = true; - this.cancelPlanUpdateTimer(); - this.pendingPlanItemIds.clear(); - this.planDeltaTextByItemId.clear(); - this.lastEmittedPlanTextByItemId.clear(); this.turnDiffs.clear(); this.oversizedTurnDiffs.clear(); } @@ -527,7 +487,7 @@ export class CodexEventHandler { return await this.createTextEvent(notification.params); case "item/plan/delta": this.completeRetryIncidentOnTurnProgress(); - return this.createPlanDeltaEvent(notification.params); + return this.plans.delta(notification.params.itemId, notification.params.delta); case "item/started": this.completeRetryIncidentOnTurnProgress(); return await this.createItemEvent(notification.params); @@ -560,9 +520,10 @@ export class CodexEventHandler { await this.flushPendingErrors(); return null; case "turn/completed": - await this.flushPendingPlanUpdates(); - this.clearPlanTurnState(); + await this.plans.flush(); + this.plans.clearTurn(); this.sessionState.currentTurnId = null; + this.sessionState.toolCallReports.releaseOpen(this.subagents.notificationSessionId(notification)); return null; case "thread/tokenUsage/updated": return this.createUsageUpdate(notification.params); @@ -594,10 +555,15 @@ export class CodexEventHandler { }); case "item/commandExecution/outputDelta": this.completeRetryIncidentOnTurnProgress(); - return this.createCommandOutputDeltaEvent(notification.params); + return this.renderFacts(this.commands.outputDelta(notification.params.itemId, notification.params.delta)); case "item/mcpToolCall/progress": this.completeRetryIncidentOnTurnProgress(); - return this.createMcpToolProgressEvent(notification.params); + // AIR does not show MCP progress. + if (this.renderer.capabilities.airClient) return null; + return this.renderer.render(McpToolReporter.progress( + notification.params.itemId, + notification.params.message, + )); case "account/rateLimits/updated": this.handleRateLimitsUpdated(notification.params); return null; @@ -613,9 +579,9 @@ export class CodexEventHandler { case "deprecationNotice": return this.createDeprecationNoticeEvent(notification.params); case "item/autoApprovalReview/started": - return this.handleGuardianApprovalReviewStarted(notification.params); + return this.renderer.render(this.guardianReviews.started(notification.params)); case "item/autoApprovalReview/completed": - return this.handleGuardianApprovalReviewCompleted(notification.params); + return this.renderer.render(this.guardianReviews.completed(notification.params)); case "thread/compacted": return this.supportsCompaction ? this.sessionState.compactions.completeLegacy( @@ -634,15 +600,18 @@ export class CodexEventHandler { case "model/rerouted": return this.createModelReroutedEvent(notification.params); case "fuzzyFileSearch/sessionUpdated": - return this.handleFuzzyFileSearchSessionUpdated(notification.params); + return this.renderer.render(this.fuzzySearches.updated(notification.params)); case "fuzzyFileSearch/sessionCompleted": - return this.handleFuzzyFileSearchSessionCompleted(notification.params); + return this.renderer.render(this.fuzzySearches.completed(notification.params)); case "thread/goal/updated": return this.createThreadGoalUpdatedEvent(notification.params); case "thread/goal/cleared": return this.createThreadGoalClearedEvent(notification.params); case "item/commandExecution/terminalInteraction": - return this.createTerminalInteractionEvent(notification.params); + return this.renderFacts(this.commands.terminalInput( + notification.params.itemId, + notification.params.stdin, + )); case "thread/attachment/updated": // Persisted attachment metadata has no ACP session update counterpart. return null; @@ -710,7 +679,8 @@ export class CodexEventHandler { private async createTextEvent(event: AgentMessageDeltaNotification): Promise { const phase = this.agentMessagePhases.get(event.itemId) ?? null; - return createAgentTextMessageChunk(event.delta, event.itemId, createCodexMessagePhaseMeta(phase)); + const meta = createMessagePhaseMeta(phase, this.sessionState.clientCapabilities.airClient); + return createAgentTextMessageChunk(event.delta, event.itemId, meta); } private async createConfigWarningEvent(event: ConfigWarningNotification): Promise { @@ -777,10 +747,12 @@ export class CodexEventHandler { return this.createGoalSessionInfoUpdate(null); } - private createGoalSessionInfoUpdate(goal: ThreadGoalSnapshot | null): UpdateSessionEvent { + /** Only AIR gets the goal. The update carries nothing else, so another client gets no update. */ + private createGoalSessionInfoUpdate(goal: ThreadGoalSnapshot | null): UpdateSessionEvent | null { + if (!this.sessionState.clientCapabilities.airClient) return null; return { sessionUpdate: "session_info_update", - _meta: {goal}, + _meta: withAirMeta(undefined, AIR_GOAL_KEY, goal), }; } @@ -791,20 +763,6 @@ export class CodexEventHandler { return this.createAgentThoughtEvent(event.delta, event.itemId); } - private createPlanDeltaEvent(event: PlanDeltaNotification): null { - if (event.delta.length === 0) { - return null; - } - const text = this.planDeltaTextByItemId.get(event.itemId) ?? ""; - const updatedText = text + event.delta; - this.planDeltaTextByItemId.set(event.itemId, updatedText); - if (this.supportsPlanUpdates) { - this.pendingPlanItemIds.add(event.itemId); - this.schedulePlanUpdate(); - } - return null; - } - private createReasoningSectionBreakEvent(event: ReasoningSummaryPartAddedNotification): UpdateSessionEvent { this.seenReasoningDeltaItemIds.add(event.itemId); return this.createAgentThoughtEvent("\n\n", event.itemId); @@ -817,30 +775,26 @@ export class CodexEventHandler { private async createItemEvent(event: ItemStartedNotification): Promise { switch (event.item.type) { case "fileChange": - return await createFileChangeUpdate(event.item, this.supportsDiffPatch); - case "commandExecution": { - if (commandExecutionUsesTerminalOutput(event.item)) { - this.terminalCommandIds.add(event.item.id); - } else { - this.terminalCommandIds.delete(event.item.id); - this.commandOutputIds.delete(event.item.id); - } - return await createCommandExecutionUpdate(event.item); - } + return this.renderer.render(await FileChangeReporter.started( + event.item, + this.sessionState.clientCapabilities.air.diffPatch, + )); + case "commandExecution": + return this.renderer.render(this.commands.started(event.item)); case "mcpToolCall": - return await createMcpToolCallUpdate(event.item); + return this.renderer.render(McpToolReporter.started(event.item)); case "dynamicToolCall": - return await createDynamicToolCallUpdate(event.item); + return this.renderer.render(DynamicToolReporter.started(event.item)); case "webSearch": - return createWebSearchStartUpdate(event.item); + return this.renderer.render(WebSearchReporter.started(event.item)); case "imageView": this.emittedImageViewItems.add(event.item.id); - return createImageViewUpdate(event.item); + return this.renderer.render(ImageViewReporter.viewed(event.item)); case "imageGeneration": this.activeImageGenerationItems.add(event.item.id); - return createImageGenerationStartUpdate(event.item); + return this.renderer.render(ImageGenerationReporter.started(event.item)); case "collabAgentToolCall": - return this.subagents.legacyCollaborationStarted(event.item); + return this.renderer.render(this.subagents.legacyCollaborationStarted(event.item)); case "agentMessage": this.rememberAgentMessagePhase(event.item); return null; @@ -850,9 +804,9 @@ export class CodexEventHandler { this.subagents.notificationSessionId({method: "item/started", params: event}), event.turnId, event.item.id, ) - : createContextCompactionStartUpdate(event.item); + : this.renderer.render(CompactionReporter.started(event.item)); case "subAgentActivity": - return this.subagents.legacyActivityStarted(event.item); + return this.renderer.render(this.subagents.legacyActivityStarted(event.item)); case "sleep": case "functionCallOutput": case "userMessage": @@ -868,54 +822,36 @@ export class CodexEventHandler { private async completeItemEvent(event: ItemCompletedNotification): Promise { switch (event.item.type) { case "fileChange": - return { - sessionUpdate: "tool_call_update", - toolCallId: event.item.id, - status: event.item.status === "completed" ? "completed" : "failed", - } + return this.renderer.render(FileChangeReporter.completed(event.item)); case "dynamicToolCall": - return { - sessionUpdate: "tool_call_update", - toolCallId: event.item.id, - name: functionToolName(event.item.tool, event.item.namespace), - status: event.item.status === "completed" ? "completed" : "failed", - } + return this.renderer.render(DynamicToolReporter.completed(event.item)); case "mcpToolCall": - return { - sessionUpdate: "tool_call_update", - toolCallId: event.item.id, - status: event.item.status === "completed" ? "completed" : "failed", - rawInput: createMcpRawInput(event.item.server, event.item.tool, event.item.arguments), - rawOutput: createMcpRawOutput(event.item.result, event.item.error), - } + return this.renderer.render(McpToolReporter.completed(event.item)); case "commandExecution": - return this.completeCommandExecutionEvent(event.item); + return this.renderer.render(this.commands.completed(event.item, true)); case "imageView": if (this.emittedImageViewItems.delete(event.item.id)) { return null; } - return createImageViewUpdate(event.item); + return this.renderer.render(ImageViewReporter.viewed(event.item)); case "imageGeneration": - if (this.activeImageGenerationItems.delete(event.item.id)) { - return createImageGenerationCompleteUpdate(event.item); - } - return createImageGenerationUpdate(event.item, { terminalStatus: true }); + return this.renderer.render(this.activeImageGenerationItems.delete(event.item.id) + ? ImageGenerationReporter.completed(event.item) + : ImageGenerationReporter.whole(event.item, {terminalStatus: true})); case "reasoning": if (this.seenReasoningDeltaItemIds.delete(event.item.id)) { return null; } return this.createCompletedReasoningEvent(event.item); case "webSearch": - return createWebSearchCompleteUpdate(event.item); + return this.renderer.render(WebSearchReporter.completed(event.item)); case "collabAgentToolCall": - return this.subagents.legacyCollaborationCompleted(event.item); + return this.renderer.render(this.subagents.legacyCollaborationCompleted(event.item)); case "agentMessage": this.rememberAgentMessagePhase(event.item); return null; - case "plan": { - const deltaText = this.planDeltaTextByItemId.get(event.item.id) ?? ""; - return await this.createCompletedPlanEvent(event.item, deltaText); - } + case "plan": + return await this.createCompletedPlanEvent(event.item); case "exitedReviewMode": return this.createExitedReviewModeEvent(event.item); case "contextCompaction": @@ -924,20 +860,23 @@ export class CodexEventHandler { this.subagents.notificationSessionId({method: "item/completed", params: event}), event.turnId, event.item.id, ) - : createContextCompactionCompleteUpdate(event.item); - //ignored types + : this.renderer.render(CompactionReporter.completed(event.item)); case "subAgentActivity": - return this.subagents.legacyActivityCompleted(event.item); + return this.renderer.render(this.subagents.legacyActivityCompleted(event.item)); + //ignored types case "sleep": case "functionCallOutput": case "userMessage": case "hookPrompt": case "enteredReviewMode": return null; - } } + private renderFacts(facts: ToolFacts | null): UpdateSessionEvent | null { + return facts === null ? null : this.renderer.render(facts); + } + private rememberAgentMessagePhase(item: ThreadItem & { type: "agentMessage" }): void { this.agentMessagePhases.set(item.id, item.phase); } @@ -951,78 +890,11 @@ export class CodexEventHandler { return this.createAgentThoughtEvent(text, item.id); } - private async createCompletedPlanEvent( - item: ThreadItem & { type: "plan" }, - deltaText: string, - ): Promise { - const text = item.text.length > 0 ? item.text : deltaText; - this.pendingPlanItemIds.delete(item.id); - if (this.pendingPlanItemIds.size === 0) { - this.cancelPlanUpdateTimer(); - } - this.planDeltaTextByItemId.delete(item.id); - if (text.length === 0) { - return null; - } - this.completedPlan = {itemId: item.id, text}; - if (this.supportsPlanUpdates) { - await this.enqueuePlanSnapshot(item.id, text); - return null; - } - return this.createPlanTextEvent(text, item.id); - } - - private schedulePlanUpdate(): void { - if (this.disposed || this.planUpdateTimer !== null) return; - this.planUpdateTimer = setTimeout(() => { - this.planUpdateTimer = null; - void this.flushPendingPlanUpdates().catch(error => { - logger.error("Failed to flush throttled plan updates", error); - }); - }, CodexEventHandler.PLAN_UPDATE_INTERVAL_MS); - } - - private cancelPlanUpdateTimer(): void { - if (this.planUpdateTimer === null) return; - clearTimeout(this.planUpdateTimer); - this.planUpdateTimer = null; - } - - private enqueuePlanSnapshot(itemId: string, text: string): Promise { - const send = async () => { - if (this.lastEmittedPlanTextByItemId.get(itemId) === text) return; - await this.session.update(this.createPlanUpdateEvent(text, itemId)); - this.lastEmittedPlanTextByItemId.set(itemId, text); - }; - const result = this.planUpdateChain.then(send); - this.planUpdateChain = result.catch(() => {}); - return result; - } - - private clearPlanTurnState(): void { - this.cancelPlanUpdateTimer(); - this.pendingPlanItemIds.clear(); - this.planDeltaTextByItemId.clear(); - this.lastEmittedPlanTextByItemId.clear(); - } - - private createPlanUpdateEvent(text: string, planId: string): UpdateSessionEvent { - return { - sessionUpdate: "plan_update", - plan: { - type: "markdown", - planId, - content: text, - }, - }; - } - - private createPlanTextEvent(text: string, messageId: string): UpdateSessionEvent { - return createAgentTextMessageChunk( - text, - messageId, - createCodexMessagePhaseMeta("final_answer"), - ); + private async createCompletedPlanEvent(item: ThreadItem & { type: "plan" }): Promise { + const completed = await this.plans.completed(item.id, item.text); + if (completed === null) return null; + this.completedPlan = {itemId: item.id, text: completed.text}; + return completed.update; } private createExitedReviewModeEvent(item: ThreadItem & { type: "exitedReviewMode" }): UpdateSessionEvent | null { @@ -1044,129 +916,6 @@ export class CodexEventHandler { ); } - private createCommandOutputDeltaEvent(event: CommandExecutionOutputDeltaNotification): UpdateSessionEvent { - if (event.delta.length > 0) { - this.commandOutputIds.add(event.itemId); - } - return this.createCommandOutputEvent(event.itemId, event.delta, this.commandOutputMode(event.itemId)); - } - - private createCommandOutputEvent( - itemId: string, - data: string, - terminalOutputMode: TerminalOutputMode - ): UpdateSessionEvent { - return { - sessionUpdate: "tool_call_update", - toolCallId: itemId, - _meta: createTerminalOutputMeta(terminalOutputMode, itemId, data), - } - } - - private createTerminalInteractionEvent(event: TerminalInteractionNotification): UpdateSessionEvent { - return this.createCommandOutputDeltaEvent({ - threadId: event.threadId, - turnId: event.turnId, - itemId: event.itemId, - delta: `\n${event.stdin}\n`, - }); - } - - private commandOutputMode(itemId: string): TerminalOutputMode { - if (this.sessionState.terminalOutputMode === "terminal_output" && !this.terminalCommandIds.has(itemId)) { - return "terminal_output_delta"; - } - return this.sessionState.terminalOutputMode; - } - - private createMcpToolProgressEvent(event: { itemId: string, message: string }): UpdateSessionEvent { - const logDelta = event.message.trim(); - return { - sessionUpdate: "tool_call_update", - toolCallId: event.itemId, - _meta: { - mcp_output_delta: { - data: logDelta, - } - } - }; - } - - static createMcpStartupUpdates(event: McpStartupCompleteEvent): UpdateSessionEvent[] { - const failedUpdates = event.failed.map((server: McpStartupCompleteEvent["failed"][number]) => this.createMcpStartupToolCallUpdate( - server.server, - `[codex-acp forwarded startup error] MCP server \`${server.server}\` failed to start: ${server.error}` - )); - const cancelledUpdates = event.cancelled.map((server: McpStartupCompleteEvent["cancelled"][number]) => this.createMcpStartupToolCallUpdate( - server, - `[codex-acp forwarded startup error] MCP server \`${server}\` startup was cancelled.` - )); - - return [...failedUpdates, ...cancelledUpdates]; - } - - private static createMcpStartupToolCallUpdate(serverName: string, message: string): UpdateSessionEvent { - return { - sessionUpdate: "tool_call", - toolCallId: this.getMcpStartupToolCallId(serverName), - kind: "other", - title: `mcp__${serverName}__startup`, - status: "failed", - content: [{ - type: "content", - content: { - type: "text", - text: message, - }, - }], - }; - } - - private static getMcpStartupToolCallId(serverName: string): string { - return `mcp_startup.${encodeURIComponent(serverName)}`; - } - - private completeCommandExecutionEvent(item: ThreadItem & { "type": "commandExecution" }): UpdateSessionEvent { - const name = commandToolName(item.source); - const update: UpdateSessionEvent = { - sessionUpdate: "tool_call_update", - toolCallId: item.id, - ...(name === undefined ? {} : {name}), - status: item.status === "completed" ? "completed" : "failed", - ...(this.sessionState.terminalOutputDeltaSupported ? {} : { - rawOutput: { - formatted_output: item.aggregatedOutput ?? "", - exit_code: item.exitCode - }, - }), - }; - - const commandHadTerminal = this.terminalCommandIds.delete(item.id); - const commandHadOutput = this.commandOutputIds.delete(item.id); - const terminalMeta: Record = {}; - if (!commandHadOutput && item.aggregatedOutput && - (commandHadTerminal || this.sessionState.terminalOutputDeltaSupported)) { - Object.assign( - terminalMeta, - createTerminalOutputMeta(this.sessionState.terminalOutputMode, item.id, item.aggregatedOutput) - ); - } - if (commandHadTerminal) { - terminalMeta["terminal_exit"] = { - exit_code: item.exitCode, - signal: null, - terminal_id: item.id - }; - } - if (Object.keys(terminalMeta).length === 0) { - return update; - } - return { - ...update, - _meta: terminalMeta, - }; - } - private async updatePlan(event: TurnPlanUpdatedNotification): Promise { const plan: PlanEntry[] = event.plan.map(value => ({ status: value.status == "inProgress" ? "in_progress" : value.status, @@ -1234,6 +983,10 @@ export class CodexEventHandler { ? RequestError.internalError(this.createTurnErrorData(params.error)) : RequestError.authRequired(this.createTurnErrorData(params.error), params.error.message); } + // The prompt error carries the message of such a failure, so the transcript does not repeat it. + if (this.failure !== null && params.error.additionalDetails === null) { + return null; + } return createAgentTextMessageChunk(`${params.error.message}\n\n`); } @@ -1445,41 +1198,6 @@ export class CodexEventHandler { }); } - private handleFuzzyFileSearchSessionUpdated( - params: FuzzyFileSearchSessionUpdatedNotification - ): UpdateSessionEvent { - const toolCallId = fuzzyFileSearchToolCallId(params.sessionId); - const started = !this.activeFuzzyFileSearchSessions.has(toolCallId); - this.activeFuzzyFileSearchSessions.add(toolCallId); - return createFuzzyFileSearchStartOrUpdate(params, started); - } - - private handleFuzzyFileSearchSessionCompleted( - params: FuzzyFileSearchSessionCompletedNotification - ): UpdateSessionEvent { - const toolCallId = fuzzyFileSearchToolCallId(params.sessionId); - this.activeFuzzyFileSearchSessions.delete(toolCallId); - return createFuzzyFileSearchComplete(params); - } - - private handleGuardianApprovalReviewStarted( - params: ItemGuardianApprovalReviewStartedNotification - ): UpdateSessionEvent { - if (this.activeGuardianApprovalReviews.has(params.reviewId)) { - return createGuardianApprovalReviewToolCallUpdate(params); - } - this.activeGuardianApprovalReviews.add(params.reviewId); - return createGuardianApprovalReviewToolCall(params); - } - - private handleGuardianApprovalReviewCompleted( - params: ItemGuardianApprovalReviewCompletedNotification - ): UpdateSessionEvent { - if (this.activeGuardianApprovalReviews.delete(params.reviewId)) { - return createGuardianApprovalReviewToolCallUpdate(params); - } - return createGuardianApprovalReviewToolCall(params); - } } function toolCallTitle(update: UpdateSessionEvent | null | undefined): string | undefined { diff --git a/src/CodexPlanStream.ts b/src/CodexPlanStream.ts new file mode 100644 index 00000000..399d6a4d --- /dev/null +++ b/src/CodexPlanStream.ts @@ -0,0 +1,151 @@ +import type {ACPSessionConnection, UpdateSessionEvent} from "./ACPSessionConnection"; +import {withAirMeta} from "./AirExtension"; +import {createAgentTextMessageChunk, createMessagePhaseMeta} from "./ContentChunks"; +import {logger} from "./Logger"; +import type {ClientCapabilities} from "./tool-calls/ClientCapabilities"; + +export const AIR_CONTENT_DELTA_KEY = "contentDelta"; + +/** + * Streams the Markdown plan that Codex writes in plan mode. + * + * - With the AIR `planContentDelta` capability, the first report of a plan is a `plan_update` with the whole text. + * Later reports carry only the appended text in `_meta.jetbrains.air.contentDelta`. + * - Another client with plan updates gets throttled `plan_update` snapshots. + * - AIR without plan updates gets the plan as appended `agent_message_chunk` text. + * - Another client without plan updates gets the whole plan as one `agent_message_chunk` when the plan completes. + * + * The completed plan item is authoritative. The stream sends only what the client does not have yet. + */ +export class CodexPlanStream { + private static readonly UPDATE_INTERVAL_MS = 150; + + /** The text that Codex streamed for each plan item. */ + private readonly streamedText = new Map(); + /** The text that the client has for each plan item. */ + private readonly reportedText = new Map(); + private readonly pendingItemIds = new Set(); + private timer: ReturnType | null = null; + private chain: Promise = Promise.resolve(); + private disposed = false; + + constructor( + private readonly session: ACPSessionConnection, + private readonly capabilities: ClientCapabilities, + ) {} + + /** Returns the update to send now, or `null` when the update waits for the throttle. */ + delta(itemId: string, delta: string): UpdateSessionEvent | null { + if (delta.length === 0) return null; + const text = (this.streamedText.get(itemId) ?? "") + delta; + this.streamedText.set(itemId, text); + if (!this.capabilities.planUpdates) { + // A client that is not AIR gets the whole plan once, when the plan item completes. + if (!this.capabilities.airClient) return null; + this.reportedText.set(itemId, text); + return planMessageChunk(delta, itemId, true); + } + this.pendingItemIds.add(itemId); + this.schedule(); + return null; + } + + /** Returns the text of the completed plan, or `null` when it is empty. */ + async completed(itemId: string, itemText: string): Promise<{text: string; update: UpdateSessionEvent | null} | null> { + const text = itemText.length > 0 ? itemText : this.streamedText.get(itemId) ?? ""; + this.pendingItemIds.delete(itemId); + if (this.pendingItemIds.size === 0) this.cancelTimer(); + this.streamedText.delete(itemId); + if (text.length === 0) return null; + if (this.capabilities.planUpdates) { + await this.enqueue(itemId, text); + return {text, update: null}; + } + return {text, update: this.remainingMessageText(itemId, text)}; + } + + async flush(): Promise { + this.cancelTimer(); + do { + const itemIds = [...this.pendingItemIds]; + this.pendingItemIds.clear(); + await Promise.all(itemIds.map(itemId => { + const text = this.streamedText.get(itemId) ?? ""; + return text.length > 0 ? this.enqueue(itemId, text) : Promise.resolve(); + })); + await this.chain; + } while (this.pendingItemIds.size > 0); + } + + clearTurn(): void { + this.cancelTimer(); + this.pendingItemIds.clear(); + this.streamedText.clear(); + this.reportedText.clear(); + } + + async dispose(): Promise { + if (this.disposed) return; + await this.flush(); + this.disposed = true; + this.clearTurn(); + } + + private remainingMessageText(itemId: string, text: string): UpdateSessionEvent | null { + const reported = this.reportedText.get(itemId) ?? ""; + this.reportedText.delete(itemId); + if (reported.length === 0) return planMessageChunk(text, itemId, this.capabilities.airClient); + if (text.startsWith(reported)) { + const rest = text.slice(reported.length); + return rest.length > 0 ? planMessageChunk(rest, itemId, this.capabilities.airClient) : null; + } + // A message chunk cannot be replaced, so the streamed text stays. + logger.log("The completed plan differs from the streamed plan text", {itemId}); + return null; + } + + private schedule(): void { + if (this.disposed || this.timer !== null) return; + this.timer = setTimeout(() => { + this.timer = null; + void this.flush().catch(error => { + logger.error("Failed to flush throttled plan updates", error); + }); + }, CodexPlanStream.UPDATE_INTERVAL_MS); + } + + private cancelTimer(): void { + if (this.timer === null) return; + clearTimeout(this.timer); + this.timer = null; + } + + private enqueue(itemId: string, text: string): Promise { + const send = async () => { + const update = this.planUpdate(itemId, text); + if (update === null) return; + await this.session.update(update); + this.reportedText.set(itemId, text); + }; + const result = this.chain.then(send); + this.chain = result.catch(() => {}); + return result; + } + + private planUpdate(itemId: string, text: string): UpdateSessionEvent | null { + const reported = this.reportedText.get(itemId); + if (reported === text) return null; + if (this.capabilities.air.planContentDelta && reported !== undefined && text.startsWith(reported)) { + return { + sessionUpdate: "plan_update", + plan: {type: "markdown", planId: itemId, content: ""}, + _meta: withAirMeta(undefined, AIR_CONTENT_DELTA_KEY, text.slice(reported.length)), + }; + } + return {sessionUpdate: "plan_update", plan: {type: "markdown", planId: itemId, content: text}}; + } +} + +function planMessageChunk(text: string, itemId: string, airClient: boolean): UpdateSessionEvent { + return createAgentTextMessageChunk(text, itemId, createMessagePhaseMeta("final_answer", airClient)); +} diff --git a/src/CodexToolCallMapper.ts b/src/CodexToolCallMapper.ts deleted file mode 100644 index 4cc17d3d..00000000 --- a/src/CodexToolCallMapper.ts +++ /dev/null @@ -1,974 +0,0 @@ -import type { ContentBlock, ToolCallContent } from "@agentclientprotocol/sdk"; -import { applyPatch, parsePatch, reversePatch, type StructuredPatch } from "diff"; -import { AIR_DIFF_PATCH_KEY, withAirMeta } from "./AirExtension"; -import { createAddedFileGitPatch, createDeletedFileGitPatch, createUpdateGitPatch } from "./GitPatch"; -import { readFile } from "node:fs/promises"; -import path from "node:path"; -import type { UpdateSessionEvent } from "./ACPSessionConnection"; -import { stripShellPrefix } from "./CommandUtils"; -import type { - FuzzyFileSearchSessionCompletedNotification, - FuzzyFileSearchSessionUpdatedNotification -} from "./app-server"; -import type { - CollabAgentToolCallStatus, - CommandAction, - CommandExecutionStatus, - DynamicToolCallStatus, - FileUpdateChange, - GuardianApprovalReview, - GuardianApprovalReviewAction, - GuardianApprovalReviewStatus, - GuardianCommandSource, - ItemGuardianApprovalReviewCompletedNotification, - ItemGuardianApprovalReviewStartedNotification, - McpToolCallError, - McpToolCallResult, - McpToolCallStatus, - PatchApplyStatus, - ThreadItem, -} from "./app-server/v2"; -import type { JsonValue } from "./app-server/serde_json/JsonValue"; -import {logger} from "./Logger"; -import { - createTerminalOutputMeta, - type TerminalOutputMode, -} from "./TerminalOutputMode"; -import {createContextCompactionMeta} from "./ContextCompactionMeta"; -import {commandToolName, functionToolName} from "./ToolCallName"; - -type CodexItemStatus = CommandExecutionStatus | PatchApplyStatus | McpToolCallStatus | DynamicToolCallStatus | CollabAgentToolCallStatus; -type AcpToolCallStatus = "pending" | "in_progress" | "completed" | "failed"; -type GuardianApprovalReviewNotification = - | ItemGuardianApprovalReviewStartedNotification - | ItemGuardianApprovalReviewCompletedNotification; -type WebSearchItem = ThreadItem & { type: "webSearch" }; -type CollabAgentToolCallItem = ThreadItem & { type: "collabAgentToolCall" }; -type SubAgentActivityItem = ThreadItem & { type: "subAgentActivity" }; -type CommandExecutionItem = ThreadItem & { type: "commandExecution" }; -type ContextCompactionItem = ThreadItem & { type: "contextCompaction" }; -type AcpToolCallEvent = Extract; - -const CONTEXT_COMPACTION_META = createContextCompactionMeta(); -function toAcpStatus(status: CodexItemStatus): AcpToolCallStatus { - switch (status) { - case "inProgress": - return "in_progress"; - case "completed": - return "completed"; - case "failed": - case "declined": - case "interrupted": - return "failed"; - } -} - -export async function createFileChangeUpdate( - item: ThreadItem & { type: "fileChange" }, - supportsDiffPatch = false, -): Promise { - const patches: ToolCallContent[] = []; - for (const change of item.changes) { - const content = await createPatchContent(change, supportsDiffPatch); - if (content) patches.push(content); - // ignore unparseable diffs - } - return { - sessionUpdate: "tool_call", - toolCallId: item.id, - title: "Editing files", - kind: "edit", - status: toAcpStatus(item.status), - content: patches, - }; -} - -export async function createCommandExecutionUpdate(item: CommandExecutionItem): Promise { - const name = commandToolName(item.source); - const commandAction = item.commandActions.length === 1 ? item.commandActions[0] : undefined; - if (commandAction) { - return { - ...createCommandActionEvent(item.id, item.status, item.cwd, commandAction), - ...(name === undefined ? {} : {name}), - }; - } - const command = stripShellPrefix(item.command); - return createTerminalCommandEvent({ - sessionUpdate: "tool_call", - toolCallId: item.id, - ...(name === undefined ? {} : {name}), - kind: "execute", - title: command, - status: toAcpStatus(item.status), - rawInput: { - command: item.command, - cwd: item.cwd, - }, - }, item.id, item.cwd); -} - -export function createCommandExecutionCompleteUpdate( - item: CommandExecutionItem, - terminalOutputMode: TerminalOutputMode, -): UpdateSessionEvent | null { - if (item.status === "inProgress") { - return null; - } - - const update: UpdateSessionEvent = { - sessionUpdate: "tool_call_update", - toolCallId: item.id, - status: item.status === "completed" ? "completed" : "failed", - rawOutput: { - formatted_output: item.aggregatedOutput ?? "", - exit_code: item.exitCode, - }, - }; - - if (!commandExecutionUsesTerminalOutput(item)) { - return update; - } - - const terminalMeta: Record = {}; - if (item.aggregatedOutput) { - Object.assign( - terminalMeta, - createTerminalOutputMeta(terminalOutputMode, item.id, item.aggregatedOutput), - ); - } - terminalMeta["terminal_exit"] = { - exit_code: item.exitCode, - signal: null, - terminal_id: item.id, - }; - - return { - ...update, - _meta: terminalMeta, - }; -} - -export async function createMcpToolCallUpdate( - item: ThreadItem & { type: "mcpToolCall" } -): Promise { - return { - ...await createExecuteToolCallUpdate( - item, - `mcp.${item.server}.${item.tool}`, - createMcpRawInput(item.server, item.tool, item.arguments), - createMcpRawOutput(item.result, item.error), - ), - _meta: { is_mcp_tool_call: true }, - }; -} - -export async function createDynamicToolCallUpdate( - item: ThreadItem & { type: "dynamicToolCall" } -): Promise { - return { - ...await createExecuteToolCallUpdate(item, item.tool, { arguments: item.arguments }), - name: functionToolName(item.tool, item.namespace), - }; -} - -export function createImageViewUpdate( - item: ThreadItem & { type: "imageView" } -): UpdateSessionEvent { - const displayPath = item.path; - return { - sessionUpdate: "tool_call", - toolCallId: item.id, - kind: "read", - name: "view_image", - title: `View Image ${displayPath}`, - status: "completed", - content: [createContent({ - type: "resource_link", - name: displayPath, - uri: displayPath, - })], - locations: [{ path: item.path }], - rawInput: { - path: item.path, - }, - }; -} - -export function createImageGenerationStartUpdate( - item: ThreadItem & { type: "imageGeneration" } -): UpdateSessionEvent { - return { - sessionUpdate: "tool_call", - toolCallId: item.id, - kind: "other", - title: "Image generation", - status: "in_progress", - rawInput: { - id: item.id, - }, - }; -} - -export function createImageGenerationCompleteUpdate( - item: ThreadItem & { type: "imageGeneration" } -): UpdateSessionEvent { - return { - sessionUpdate: "tool_call_update", - toolCallId: item.id, - status: imageGenerationTerminalStatus(item.status), - content: imageGenerationContent(item), - rawOutput: imageGenerationRawOutput(item), - }; -} - -export function createImageGenerationUpdate( - item: ThreadItem & { type: "imageGeneration" }, - options?: { terminalStatus?: boolean }, -): UpdateSessionEvent { - return { - sessionUpdate: "tool_call", - toolCallId: item.id, - kind: "other", - title: "Image generation", - status: options?.terminalStatus - ? imageGenerationTerminalStatus(item.status) - : imageGenerationToolStatus(item.status), - content: imageGenerationContent(item), - rawOutput: imageGenerationRawOutput(item), - }; -} - -export function createContextCompactionStartUpdate( - item: ContextCompactionItem, -): UpdateSessionEvent { - return { - sessionUpdate: "tool_call", - toolCallId: item.id, - kind: "think", - title: "Compact conversation", - status: "in_progress", - _meta: CONTEXT_COMPACTION_META, - }; -} - -export function createContextCompactionCompleteUpdate( - item: ContextCompactionItem, -): UpdateSessionEvent { - return { - sessionUpdate: "tool_call_update", - toolCallId: item.id, - title: "Compact conversation", - status: "completed", - _meta: CONTEXT_COMPACTION_META, - }; -} - -export function createCompletedContextCompactionUpdate( - item: ContextCompactionItem, -): UpdateSessionEvent { - return { - sessionUpdate: "tool_call", - toolCallId: item.id, - kind: "think", - title: "Compact conversation", - status: "completed", - _meta: CONTEXT_COMPACTION_META, - }; -} - -export async function createExecuteToolCallUpdate( - item: ThreadItem & ({ type: "mcpToolCall" } | { type: "dynamicToolCall" }), - title: string, - rawInput?: Record, - rawOutput?: Record, -): Promise { - return { - sessionUpdate: "tool_call", - toolCallId: item.id, - kind: "execute", - title: title, - status: toAcpStatus(item.status), - rawInput: rawInput, - rawOutput: rawOutput, - }; -} - -export function createMcpRawInput(server: string, tool: string, argumentsValue: JsonValue): Record { - return { - server, - tool, - arguments: argumentsValue, - }; -} - -export function createMcpRawOutput( - result: McpToolCallResult | null, - error: McpToolCallError | null, -): Record | undefined { - if (result === null && error === null) { - return undefined; - } - - return { - result, - error, - }; -} - -export function guardianApprovalReviewToolCallId(reviewId: string): string { - return `guardian_assessment:${reviewId}`; -} - -export function createGuardianApprovalReviewToolCall( - event: GuardianApprovalReviewNotification, -): UpdateSessionEvent { - return { - sessionUpdate: "tool_call", - toolCallId: guardianApprovalReviewToolCallId(event.reviewId), - kind: "think", - title: "Guardian Review", - status: toAcpGuardianApprovalReviewStatus(event.review.status), - content: createGuardianApprovalReviewContent(event.review, event.action), - rawInput: event as unknown as Record, - }; -} - -export function createGuardianApprovalReviewToolCallUpdate( - event: GuardianApprovalReviewNotification, -): UpdateSessionEvent { - return { - sessionUpdate: "tool_call_update", - toolCallId: guardianApprovalReviewToolCallId(event.reviewId), - status: toAcpGuardianApprovalReviewStatus(event.review.status), - content: createGuardianApprovalReviewContent(event.review, event.action), - rawOutput: event as unknown as Record, - }; -} - -export function fuzzyFileSearchToolCallId(sessionId: string): string { - return `fuzzyFileSearch.${sessionId}`; -} - -export function createFuzzyFileSearchStartOrUpdate( - event: FuzzyFileSearchSessionUpdatedNotification, - started: boolean -): UpdateSessionEvent { - const toolCallId = fuzzyFileSearchToolCallId(event.sessionId); - const title = createSearchTitle(event.query, null); - const locations = event.files.map((file) => ({ - path: path.isAbsolute(file.path) ? file.path : path.join(file.root, file.path), - })); - - if (started) { - return { - sessionUpdate: "tool_call", - toolCallId, - kind: "search", - title, - status: "in_progress", - locations, - rawInput: { - query: event.query, - }, - }; - } - - return { - sessionUpdate: "tool_call_update", - toolCallId, - title, - status: "in_progress", - locations, - }; -} - -export function createFuzzyFileSearchComplete( - event: FuzzyFileSearchSessionCompletedNotification -): UpdateSessionEvent { - return { - sessionUpdate: "tool_call_update", - toolCallId: fuzzyFileSearchToolCallId(event.sessionId), - status: "completed", - }; -} - -export function createWebSearchStartUpdate( - item: WebSearchItem -): UpdateSessionEvent { - return { - sessionUpdate: "tool_call", - toolCallId: item.id, - kind: "search", - title: formatWebSearchTitle(item), - status: "in_progress", - rawInput: createWebSearchRawInput(item), - }; -} - -export function createWebSearchCompleteUpdate( - item: WebSearchItem -): UpdateSessionEvent { - return { - sessionUpdate: "tool_call_update", - toolCallId: item.id, - title: formatWebSearchTitle(item), - status: "completed", - rawInput: createWebSearchRawInput(item), - }; -} - -function createWebSearchRawInput(item: WebSearchItem): Record { - return { - type: item.type, - id: item.id, - query: item.query, - action: item.action, - }; -} - -export function createCollabAgentToolCallUpdate( - item: CollabAgentToolCallItem -): UpdateSessionEvent { - return { - sessionUpdate: "tool_call", - toolCallId: item.id, - kind: "other", - title: item.tool, - status: toAcpStatus(item.status), - rawInput: createCollabAgentToolCallRawInput(item), - _meta: createCollabAgentToolCallMeta(item), - }; -} - -export function createCollabAgentToolCallCompleteUpdate( - item: CollabAgentToolCallItem -): UpdateSessionEvent { - return { - sessionUpdate: "tool_call_update", - toolCallId: item.id, - title: item.tool, - status: toAcpStatus(item.status), - rawInput: createCollabAgentToolCallRawInput(item), - _meta: createCollabAgentToolCallMeta(item), - }; -} - -function createCollabAgentToolCallRawInput(item: CollabAgentToolCallItem) { - return { - prompt: item.prompt, - senderThreadId: item.senderThreadId, - receiverThreadIds: item.receiverThreadIds, - agentsStates: item.agentsStates, - model: item.model, - reasoningEffort: item.reasoningEffort, - status: item.status, - }; -} - -function createCollabAgentToolCallMeta(item: CollabAgentToolCallItem) { - return { - codex: { - collaboration: { - tool: item.tool, - senderThreadId: item.senderThreadId, - receiverThreadIds: item.receiverThreadIds, - }, - }, - }; -} - -export function createSubAgentActivityUpdate( - item: SubAgentActivityItem, - status: "in_progress" | "completed", - sessionUpdate: "tool_call" | "tool_call_update", -): UpdateSessionEvent { - const name = item.agentPath.split("/").filter(Boolean).at(-1) ?? "subagent"; - const title = formatSubAgentActivityTitle(item.kind, name); - const common = { - toolCallId: item.id, - status, - rawInput: { - agentThreadId: item.agentThreadId, - agentPath: item.agentPath, - activityKind: item.kind, - }, - _meta: { - codex: { - subagent: { - threadId: item.agentThreadId, - path: item.agentPath, - activity: item.kind, - }, - }, - }, - }; - if (sessionUpdate === "tool_call") { - return { - sessionUpdate, - title, - kind: "other", - ...common, - }; - } - return { - sessionUpdate, - ...common, - }; -} - -function formatSubAgentActivityTitle(kind: SubAgentActivityItem["kind"], name: string): string { - switch (kind) { - case "started": - return `Start subagent ${name}`; - case "interacted": - return `Interact with subagent ${name}`; - case "interrupted": - return `Interrupt subagent ${name}`; - case "completed": - return `Complete subagent ${name}`; - } -} - -export function formatWebSearchTitle(item: WebSearchItem): string { - const action = item.action; - if (!action) { - return item.query ? `Web search: ${item.query}` : "Web search"; - } - switch (action.type) { - case "search": { - const queries = action.queries?.filter((query) => query && query.length > 0) ?? []; - const query = action.query ?? (queries.length > 0 ? queries.join(", ") : null) ?? item.query; - return query ? `Web search: ${query}` : "Web search"; - } - case "openPage": - return action.url ? `Open page: ${action.url}` : "Open page"; - case "findInPage": { - const pattern = action.pattern ? ` for '${action.pattern}'` : ""; - const url = action.url ? ` in ${action.url}` : ""; - return `Find in page${pattern}${url}`.trim(); - } - case "other": - return "Web search"; - } -} - -export function createCommandActionEvent( - id: string, - status: CommandExecutionStatus, - cwd: string, - commandAction: CommandAction -): AcpToolCallEvent { - const acpStatus = toAcpStatus(status); - switch (commandAction.type) { - case "read": - return { - sessionUpdate: "tool_call", - toolCallId: id, - status: acpStatus, - kind: "read", - title: `Read file '${commandAction.path}'`, - locations: [{ path: commandAction.path }], - }; - case "search": - return { - sessionUpdate: "tool_call", - toolCallId: id, - status: acpStatus, - kind: "search", - title: createSearchTitle(commandAction.query, commandAction.path), - }; - case "listFiles": { - const title = commandAction.path - ? `List files in '${commandAction.path}'` - : "List files"; - return { - sessionUpdate: "tool_call", - toolCallId: id, - status: acpStatus, - kind: "read", - title: title, - }; - } - case "unknown": - return createTerminalCommandEvent({ - sessionUpdate: "tool_call", - toolCallId: id, - status: acpStatus, - kind: "execute", - title: stripShellPrefix(commandAction.command), - rawInput: { - command: commandAction.command, - cwd, - }, - }, id, cwd); - } -} - -export function commandExecutionUsesTerminalOutput(item: CommandExecutionItem): boolean { - const commandAction = item.commandActions.length === 1 ? item.commandActions[0] : undefined; - return commandAction === undefined || commandAction.type === "unknown"; -} - -function createTerminalCommandEvent( - event: AcpToolCallEvent, - terminalId: string, - cwd: string, -): AcpToolCallEvent { - const { rawInput, ...eventWithoutRawInput } = event; - return { - ...eventWithoutRawInput, - content: [{ type: "terminal", terminalId }], - ...(rawInput === undefined ? {} : { rawInput }), - _meta: { - terminal_info: { - cwd, - terminal_id: terminalId, - }, - }, - }; -} - -function createSearchTitle(query: string | null, path: string | null): string { - if (query && path) { - return `Search for '${query}' in ${path}`; - } else if (query) { - return `Search for '${query}'`; - } else if (path) { - return `Search in '${path}'`; - } - return "Search"; -} - -function toAcpGuardianApprovalReviewStatus(status: GuardianApprovalReviewStatus): AcpToolCallStatus { - switch (status) { - case "inProgress": - return "in_progress"; - case "approved": - return "completed"; - case "denied": - case "aborted": - case "timedOut": - return "failed"; - } -} - -function createGuardianApprovalReviewContent( - review: GuardianApprovalReview, - action: GuardianApprovalReviewAction, -): ToolCallContent[] { - const lines = [`Status: ${formatGuardianApprovalReviewStatus(review.status)}`]; - const actionSummary = createGuardianApprovalReviewActionSummary(action); - if (actionSummary) { - lines.push(`Action: ${actionSummary}`); - } - if (review.riskLevel) { - lines.push(`Risk: ${review.riskLevel}`); - } - if (review.userAuthorization) { - lines.push(`Authorization: ${review.userAuthorization}`); - } - if (review.rationale?.trim()) { - lines.push(`Rationale: ${review.rationale}`); - } - - return [{ - type: "content", - content: { - type: "text", - text: lines.join("\n"), - }, - }]; -} - -function formatGuardianApprovalReviewStatus(status: GuardianApprovalReviewStatus): string { - switch (status) { - case "inProgress": - return "In progress"; - case "approved": - return "Approved"; - case "denied": - return "Denied"; - case "aborted": - return "Aborted"; - case "timedOut": - return "Timed out"; - } -} - -function createGuardianApprovalReviewActionSummary(action: GuardianApprovalReviewAction): string | null { - switch (action.type) { - case "command": - return `${guardianCommandSourceLabel(action.source)} ${action.command}`; - case "execve": { - const command = action.argv.length > 0 ? action.argv : [action.program]; - return `${guardianCommandSourceLabel(action.source)} ${shellJoin(command)}`; - } - case "writeStdin": - return `write stdin to process ${action.processId}`; - case "applyPatch": - if (action.files.length === 1) { - return `apply_patch touching ${action.files[0]}`; - } - return `apply_patch touching ${action.files.length} files`; - case "networkAccess": { - const label = action.target.length > 0 ? action.target : action.host; - return `network access to ${label}`; - } - case "mcpToolCall": { - const label = action.connectorName ?? action.server; - return `MCP ${action.toolName} on ${label}`; - } - case "requestPermissions": - return action.reason ?? "request additional permissions"; - } -} - -function guardianCommandSourceLabel(source: GuardianCommandSource): string { - switch (source) { - case "shell": - return "shell"; - case "unifiedExec": - return "exec"; - } -} - -function shellJoin(args: string[]): string { - return args.map(shellQuote).join(" "); -} - -function shellQuote(arg: string): string { - if (arg.length === 0) { - return "''"; - } - if (/^[A-Za-z0-9_/:=+.,@%-]+$/.test(arg)) { - return arg; - } - return `'${arg.replace(/'/g, `'\\''`)}'`; -} - -function imageGenerationToolStatus(status: string): AcpToolCallStatus { - switch (status) { - case "completed": - return "completed"; - case "generating": - case "in_progress": - case "inProgress": - case "incomplete": - return "in_progress"; - case "failed": - return "failed"; - default: - return "completed"; - } -} - -function imageGenerationTerminalStatus(status: string): AcpToolCallStatus { - switch (status) { - case "failed": - return "failed"; - case "completed": - case "generating": - case "in_progress": - case "inProgress": - case "incomplete": - default: - return "completed"; - } -} - -function imageGenerationContent( - item: ThreadItem & { type: "imageGeneration" } -): ToolCallContent[] { - const content: ToolCallContent[] = []; - - if (item.revisedPrompt && item.revisedPrompt.trim() !== "") { - content.push(createContent({ - type: "text", - text: `Revised prompt: ${item.revisedPrompt}`, - })); - } - - if (item.result.trim() !== "") { - const image: ContentBlock = item.savedPath && item.savedPath.trim() !== "" - ? { - type: "image", - data: item.result, - mimeType: "image/png", - uri: item.savedPath, - } - : { - type: "image", - data: item.result, - mimeType: "image/png", - }; - content.push(createContent(image)); - } - - return content; -} - -function imageGenerationRawOutput( - item: ThreadItem & { type: "imageGeneration" } -): Record { - const output: Record = { - status: item.status, - revisedPrompt: item.revisedPrompt, - result: item.result, - }; - if ("savedPath" in item) { - output["savedPath"] = item.savedPath ?? null; - } - return output; -} - -function createContent(content: ContentBlock): ToolCallContent { - return { - type: "content", - content, - }; -} - -async function createPatchContent( - change: FileUpdateChange, - supportsDiffPatch: boolean, -): Promise { - try { - switch (change.kind.type) { - case "add": - return createAddFileContent(change, supportsDiffPatch); - case "delete": - return createDeleteFileContent(change, supportsDiffPatch); - case "update": - return await createUpdateFileContent(change, change.kind.move_path, supportsDiffPatch); - } - } catch (error) { - logger.log(`Error processing file update change: ${error}`); - return null; - } -} - -function createAddFileContent( - change: FileUpdateChange, - supportsDiffPatch: boolean, -): ToolCallContent { - // app-server always returns file content instead of diff - const patch = supportsDiffPatch ? createAddedFileGitPatch(change.path, change.diff) : null; - if (patch !== null) { - return createPatchOnlyContent(change.path, "add", patch); - } - return { - type: "diff", - oldText: null, - newText: change.diff, - path: change.path, - _meta: { kind: "add" }, - }; -} - -async function createUpdateFileContent( - change: FileUpdateChange, - movePath: string | null, - supportsDiffPatch: boolean, -): Promise { - const unifiedDiff = recoverCorruptedDiff(change.diff); - const targetPath = movePath ?? change.path; - - const gitPatch = supportsDiffPatch ? createUpdateGitPatch(change.path, targetPath, unifiedDiff) : null; - if (gitPatch !== null) { - return createPatchOnlyContent(targetPath, "update", gitPatch); - } - - // The standard diff needs the file text, so it reads the file and applies the Codex hunks. - const patch = parseSinglePatch(unifiedDiff); - if (patch === null) { - logger.log("Skipped a file change whose diff has no single valid patch", {path: change.path}); - return null; - } - - const oldContent = await readFileContent(change.path); - if (oldContent !== null) { - const patchedContent = applyPatch(oldContent, patch); - if (patchedContent === false) { - // If Codex runs in full access mode, the file might already be patched. - // we can verify this by checking if the reverted patch applies. - const revertedContent = applyPatch(oldContent, reversePatch(patch)); - if (revertedContent !== false) { - return createUpdateDiffContent(targetPath, revertedContent, oldContent); - } - return null; - } - return createUpdateDiffContent(targetPath, oldContent, patchedContent); - } - - if (!movePath) return null; - const newContent = await readFileContent(movePath); - if (newContent === null) return null; - - const revertedContent = applyPatch(newContent, reversePatch(patch)); - if (revertedContent === false) return null; - - return createUpdateDiffContent(movePath, revertedContent, newContent); -} - -function parseSinglePatch(diff: string): StructuredPatch | null { - try { - const patches = parsePatch(diff); - return patches.length === 1 ? patches[0]! : null; - } catch { - return null; - } -} - -function createUpdateDiffContent(path: string, oldText: string, newText: string): ToolCallContent { - return { - type: "diff", - oldText, - newText, - path, - _meta: { kind: "update" }, - }; -} - -function createDeleteFileContent( - change: FileUpdateChange, - supportsDiffPatch: boolean, -): ToolCallContent { - // app-server always returns file content instead of diff - const patch = supportsDiffPatch ? createDeletedFileGitPatch(change.path, change.diff) : null; - if (patch !== null) { - return createPatchOnlyContent(change.path, "delete", patch); - } - return { - type: "diff", - oldText: change.diff, - newText: "", - path: change.path, - _meta: { kind: "delete" }, - }; -} - -function createPatchOnlyContent(path: string, kind: string, patch: string): ToolCallContent { - return { - type: "diff", - oldText: null, - newText: "", - path, - _meta: withAirMeta({ kind }, AIR_DIFF_PATCH_KEY, { - version: 1, - format: "git_patch", - text: patch, - }), - }; -} - -async function readFileContent(filePath: string): Promise { - return await readFile(filePath, { encoding: "utf8" }).catch(() => null); -} - -/** - * Fix unified diff content corrupted by codex agent. - * Removes synthetic "Moved to" from the end. - */ -function recoverCorruptedDiff(diff: string): string { - return diff.replace(/\n\nMoved to: .*$/, ""); -} diff --git a/src/ContentChunks.ts b/src/ContentChunks.ts index 2bef82e8..1f25b3bf 100644 --- a/src/ContentChunks.ts +++ b/src/ContentChunks.ts @@ -1,13 +1,15 @@ import type {ContentBlock} from "@agentclientprotocol/sdk"; import type {UpdateSessionEvent} from "./ACPSessionConnection"; +import {AIR_MESSAGE_PHASE_KEY, airOnlyMeta} from "./AirExtension"; type AcpMeta = Record; -export function createCodexMessagePhaseMeta(phase: string | null | undefined): AcpMeta | undefined { +/** The Codex phase of an agent message. Only AIR gets it, in `_meta.jetbrains.air.phase`. */ +export function createMessagePhaseMeta(phase: string | null | undefined, airClient: boolean): AcpMeta | undefined { if (!phase) { return undefined; } - return { codex: { phase } }; + return airOnlyMeta(airClient, AIR_MESSAGE_PHASE_KEY, phase); } export function createUserMessageChunk(content: ContentBlock, messageId?: string, meta?: AcpMeta): UpdateSessionEvent { diff --git a/src/ContextCompactionMeta.ts b/src/ContextCompactionMeta.ts index dbffc7d2..ce9eb2b0 100644 --- a/src/ContextCompactionMeta.ts +++ b/src/ContextCompactionMeta.ts @@ -1,8 +1,12 @@ -export const CONTEXT_COMPACTION_META_KEY = "contextCompaction"; export const CONTEXT_COMPACTION_META_VERSION = 1; export type ContextCompactionTrigger = "manual" | "automatic"; +/** + * Facts of a synthetic ACP context-compaction tool call, in `_meta.jetbrains.air.contextCompaction`. + * The standard toolCallId and status fields own lifecycle identity and phase; + * this extension carries only compaction-specific facts. + */ export interface ContextCompactionMetadata { version: typeof CONTEXT_COMPACTION_META_VERSION; trigger?: ContextCompactionTrigger; @@ -12,18 +16,11 @@ export interface ContextCompactionMetadata { error?: string; } -/** - * Provider-neutral metadata for a synthetic ACP context-compaction tool call. - * The standard toolCallId and status fields own lifecycle identity and phase; - * this extension carries only compaction-specific facts. - */ -export function createContextCompactionMeta( +export function createContextCompactionMetadata( metadata: Omit = {}, -): Record { +): ContextCompactionMetadata { return { - [CONTEXT_COMPACTION_META_KEY]: { - version: CONTEXT_COMPACTION_META_VERSION, - ...metadata, - }, + version: CONTEXT_COMPACTION_META_VERSION, + ...metadata, }; } diff --git a/src/PlanCapabilities.ts b/src/PlanCapabilities.ts deleted file mode 100644 index 9f1903bc..00000000 --- a/src/PlanCapabilities.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type * as acp from "@agentclientprotocol/sdk"; - -export function clientSupportsPlanUpdates( - clientCapabilities?: acp.ClientCapabilities | null, -): boolean { - return clientCapabilities?.plan != null; -} diff --git a/src/ResponseItemHistoryFallback.ts b/src/ResponseItemHistoryFallback.ts index 09f1a0fd..14414495 100644 --- a/src/ResponseItemHistoryFallback.ts +++ b/src/ResponseItemHistoryFallback.ts @@ -4,9 +4,11 @@ import type { ContentBlock } from "@agentclientprotocol/sdk"; import type { UpdateSessionEvent } from "./ACPSessionConnection"; import { stripShellPrefix } from "./CommandUtils"; import type { CommandAction, Thread, ThreadItem } from "./app-server/v2"; -import { createCommandActionEvent } from "./CodexToolCallMapper"; -import { createTerminalOutputMeta, type TerminalOutputMode } from "./TerminalOutputMode"; -import { createAgentMessageChunk, createCodexMessagePhaseMeta } from "./ContentChunks"; +import { AcpToolCallRenderer } from "./tool-calls/AcpToolCallRenderer"; +import type { ClientCapabilities } from "./tool-calls/ClientCapabilities"; +import { commandActionFacts } from "./tool-calls/reporters/CommandReporter"; +import type { ToolFacts } from "./tool-calls/ToolFacts"; +import { createAgentMessageChunk, createMessagePhaseMeta } from "./ContentChunks"; import { functionToolName } from "./ToolCallName"; type JsonRecord = Record; @@ -16,7 +18,7 @@ type AcpToolCallUpdateStatus = NonNullable["status"]>; type LegacyFunctionCallUpdate = { - update: AcpToolCallEvent; + facts: ToolFacts; usesTerminal: boolean; isExecCommand: boolean; }; @@ -41,7 +43,7 @@ function historyFallbackUpdateKey(update: UpdateSessionEvent): string | null { export async function createResponseItemHistoryFallbackUpdates( thread: Thread, - terminalOutputMode: TerminalOutputMode, + capabilities: ClientCapabilities, ): Promise { if (!thread.path) { return null; @@ -54,14 +56,19 @@ export async function createResponseItemHistoryFallbackUpdates( return null; } - return parseResponseItemHistoryFallback(contents, terminalOutputMode, toolCallIdsFromThread(thread)); + return parseResponseItemHistoryFallback( + contents, + capabilities, + toolCallIdsFromThread(thread), + ); } export function parseResponseItemHistoryFallback( contents: string, - terminalOutputMode: TerminalOutputMode, + capabilities: ClientCapabilities, existingToolCallIds: Set = new Set(), ): UpdateSessionEvent[] | null { + const renderer = new AcpToolCallRenderer(capabilities); const updates: UpdateSessionEvent[] = []; const terminalToolCallIds = new Set(); const execToolCallIds = new Set(); @@ -100,7 +107,7 @@ export function parseResponseItemHistoryFallback( switch (item["type"]) { case "message": - pushUpdates(createMessageUpdates(item)); + pushUpdates(createMessageUpdates(item, capabilities.airClient)); break; case "reasoning": pushUpdates(createReasoningUpdates(item)); @@ -119,14 +126,14 @@ export function parseResponseItemHistoryFallback( break; } recoveredFunctionCall = true; - emittedToolCallIds.add(result.update.toolCallId); + emittedToolCallIds.add(result.facts.toolCallId); if (result.usesTerminal) { - terminalToolCallIds.add(result.update.toolCallId); + terminalToolCallIds.add(result.facts.toolCallId); } if (result.isExecCommand) { - execToolCallIds.add(result.update.toolCallId); + execToolCallIds.add(result.facts.toolCallId); } - pushUpdates([result.update]); + pushUpdates([renderer.render(result.facts)]); break; } case "function_call_output": { @@ -134,14 +141,9 @@ export function parseResponseItemHistoryFallback( if (toolCallId && skippedToolCallIds.has(toolCallId)) { break; } - const update = createFunctionCallOutputUpdate( - item, - terminalOutputMode, - terminalToolCallIds, - execToolCallIds, - ); - if (update) { - pushUpdates([update]); + const facts = createFunctionCallOutputFacts(item, terminalToolCallIds, execToolCallIds); + if (facts) { + pushUpdates([renderer.render(facts)]); } break; } @@ -227,7 +229,7 @@ function isLegacyResponseItemType(type: string): boolean { } } -function createMessageUpdates(item: JsonRecord): UpdateSessionEvent[] { +function createMessageUpdates(item: JsonRecord, airClient: boolean): UpdateSessionEvent[] { const role = item["role"]; if (role === "user") { // User response items can include bootstrap context; user_message events are the visible source. @@ -239,7 +241,7 @@ function createMessageUpdates(item: JsonRecord): UpdateSessionEvent[] { const phase = stringValue(item["phase"]); return contentBlocksFromResponseContent(item["content"]).map((content) => ( - createAgentMessageChunk(content, undefined, createCodexMessagePhaseMeta(phase)) + createAgentMessageChunk(content, undefined, createMessagePhaseMeta(phase, airClient)) )); } @@ -378,8 +380,8 @@ function createFunctionCallUpdate(item: JsonRecord): LegacyFunctionCallUpdate | const commandAction = command ? inferCommandAction(command, cwd) : null; if (commandAction) { return { - update: { - ...createCommandActionEvent(toolCallId, "inProgress", cwd, commandAction), + facts: { + ...commandActionFacts(toolCallId, "inProgress", cwd, commandAction), name: toolName, }, usesTerminal: false, @@ -387,33 +389,25 @@ function createFunctionCallUpdate(item: JsonRecord): LegacyFunctionCallUpdate | }; } - const update: AcpToolCallEvent = { - sessionUpdate: "tool_call", + const usesTerminal = functionCallUsesTerminal(item); + const facts: ToolFacts = { toolCallId, + report: "start", name: toolName, kind: toolKindForFunctionCall(name), title: titleForFunctionCall(name, args), status: "in_progress", - rawInput: rawInputForFunctionCall(name, args), - }; - - if (!functionCallUsesTerminal(item)) { - return { update, usesTerminal: false, isExecCommand }; - } - - return { - update: withTerminalContent(update, toolCallId, cwd), - usesTerminal: true, - isExecCommand, + input: rawInputForFunctionCall(name, args), + ...(usesTerminal ? { terminal: { cwd } } : {}), }; + return { facts, usesTerminal, isExecCommand }; } -function createFunctionCallOutputUpdate( +function createFunctionCallOutputFacts( item: JsonRecord, - terminalOutputMode: TerminalOutputMode, terminalToolCallIds: Set, execToolCallIds: Set, -): UpdateSessionEvent | null { +): ToolFacts | null { const toolCallId = stringValue(item["call_id"]); if (!toolCallId) { return null; @@ -422,36 +416,23 @@ function createFunctionCallOutputUpdate( const output = outputText(item["output"]); const exitCode = parseExitCode(item["output"], output); const status = statusFromExitCode(exitCode, output, execToolCallIds.has(toolCallId)); - if (!terminalToolCallIds.has(toolCallId)) { + const facts: ToolFacts = { toolCallId, report: "update", status }; + if (terminalToolCallIds.has(toolCallId)) { return { - sessionUpdate: "tool_call_update", - toolCallId, - status, - rawOutput: { output: item["output"] }, + ...facts, + ...(output.length > 0 ? { terminalOutput: output } : {}), + terminalExit: { exitCode }, + standard: { commandEnd: { output, exitCode, terminal: true, streamed: false, replay: true } }, }; } - - const meta: Record = { - terminal_exit: { - exit_code: exitCode, - signal: null, - terminal_id: toolCallId, - }, - }; - if (output.length > 0) { - Object.assign(meta, createTerminalOutputMeta(terminalOutputMode, toolCallId, output)); + // For AIR, a read, search or list command shows its output as the result. + if (execToolCallIds.has(toolCallId)) { + const standard = { content: null, rawOutput: { output: item["output"] } }; + return output.length > 0 + ? { ...facts, result: [{ type: "content", content: { type: "text", text: output } }], standard } + : { ...facts, standard }; } - - return { - sessionUpdate: "tool_call_update", - toolCallId, - status, - rawOutput: { - formatted_output: output, - exit_code: exitCode, - }, - _meta: meta, - }; + return { ...facts, opaqueResult: { output: item["output"] } }; } function parseFunctionArguments(value: unknown): unknown { @@ -466,7 +447,7 @@ function parseFunctionArguments(value: unknown): unknown { } } -function rawInputForFunctionCall(name: string, args: unknown): unknown { +function rawInputForFunctionCall(name: string, args: unknown): Record { if (name === "exec_command") { const record = asRecord(args); if (record) { @@ -1030,25 +1011,6 @@ function absolutizePath(cwd: string, targetPath: string): string { return path.join(cwd, targetPath); } -function withTerminalContent( - event: AcpToolCallEvent, - terminalId: string, - cwd: string, -): AcpToolCallEvent { - const { rawInput, ...eventWithoutRawInput } = event; - return { - ...eventWithoutRawInput, - content: [{ type: "terminal", terminalId }], - ...(rawInput === undefined ? {} : { rawInput }), - _meta: { - terminal_info: { - cwd, - terminal_id: terminalId, - }, - }, - }; -} - function outputText(output: unknown): string { if (typeof output === "string") { return output; diff --git a/src/TerminalOutputMode.ts b/src/TerminalOutputMode.ts deleted file mode 100644 index 610f902b..00000000 --- a/src/TerminalOutputMode.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type * as acp from "@agentclientprotocol/sdk"; - -export type TerminalOutputMode = "terminal_output" | "terminal_output_delta"; - -export function resolveTerminalOutputMode( - clientCapabilities?: acp.ClientCapabilities | null -): TerminalOutputMode { - const meta = clientCapabilities?._meta; - if (meta?.["terminal_output_delta"] === true) { - return "terminal_output_delta"; - } - if (meta?.["terminal_output"] === true) { - return "terminal_output"; - } - return "terminal_output_delta"; -} - -export function clientSupportsTerminalOutputDelta( - clientCapabilities?: acp.ClientCapabilities | null -): boolean { - return clientCapabilities?._meta?.["terminal_output_delta"] === true; -} - -export function createTerminalOutputMeta( - mode: TerminalOutputMode, - terminalId: string, - data: string -): Record { - switch (mode) { - case "terminal_output": - return { - terminal_output: { - data, - terminal_id: terminalId, - }, - }; - case "terminal_output_delta": - return { - terminal_output_delta: { - data, - terminal_id: terminalId, - }, - }; - } -} diff --git a/src/ToolCallReportingConnection.ts b/src/ToolCallReportingConnection.ts new file mode 100644 index 00000000..99643933 --- /dev/null +++ b/src/ToolCallReportingConnection.ts @@ -0,0 +1,74 @@ +import * as acp from "@agentclientprotocol/sdk"; +import type {AcpClientConnection, UpdateSessionEvent} from "./ACPSessionConnection"; +import {ToolCallReports} from "./ToolCallReports"; + +type SessionUpdateParams = {sessionId: string; update: UpdateSessionEvent}; + +/** + * Sends the notifications and requests of the adapter to the ACP client. + * + * Every emission site of the adapter uses this connection. + * So each tool call report goes through the same changed-fields filter, + * whether it comes from a live event, a history replay, a permission flow or an async task. + */ +export class ToolCallReportingConnection { + readonly reports = new ToolCallReports(); + + constructor(private readonly client: AcpClientConnection) {} + + /** Returns this connection with the signature of the SDK connection. */ + asClientConnection(): AcpClientConnection { + return this as unknown as AcpClientConnection; + } + + async notify(method: string, params?: unknown): Promise { + if (method === acp.methods.client.session.update && isSessionUpdateParams(params)) { + const update = this.reports.prepare(params.sessionId, params.update); + if (update === null) return; + await this.client.notify(method, {...params, update}); + return; + } + await this.client.notify(method, params); + } + + async request(method: string, params?: unknown, options?: acp.SendRequestOptions): Promise { + if (method !== acp.methods.client.session.requestPermission || !isPermissionRequestParams(params)) { + return await this.client.request(method, params, options); + } + // The client merges the request tool call into the stored tool call, like an update. + // A cancelled or failed request may leave the client without these fields, + // so the adapter then forgets the open record and sends every field again. + this.reports.prepare(params.sessionId, {sessionUpdate: "tool_call_update", ...params.toolCall}); + let response: unknown; + try { + response = await this.client.request(method, params, options); + } catch (error) { + this.reports.forgetOpen(params.sessionId, params.toolCall.toolCallId); + throw error; + } + if (isCancelled(response)) this.reports.forgetOpen(params.sessionId, params.toolCall.toolCallId); + return response; + } +} + +function isCancelled(response: unknown): boolean { + return (response as Partial | null)?.outcome?.outcome === "cancelled"; +} + +function isPermissionRequestParams(value: unknown): value is acp.RequestPermissionRequest { + if (value === null || typeof value !== "object") return false; + const record = value as Record; + const toolCall = record["toolCall"]; + return typeof record["sessionId"] === "string" + && toolCall !== null + && typeof toolCall === "object" + && typeof (toolCall as Record)["toolCallId"] === "string"; +} + +function isSessionUpdateParams(value: unknown): value is SessionUpdateParams { + if (value === null || typeof value !== "object") return false; + const record = value as Record; + return typeof record["sessionId"] === "string" + && record["update"] !== null + && typeof record["update"] === "object"; +} diff --git a/src/ToolCallReports.ts b/src/ToolCallReports.ts new file mode 100644 index 00000000..33398245 --- /dev/null +++ b/src/ToolCallReports.ts @@ -0,0 +1,178 @@ +import type {UpdateSessionEvent} from "./ACPSessionConnection"; +import {logger} from "./Logger"; + +type ToolCallReport = Extract; + +/** The client appends these metadata values, so the adapter never compares them with an earlier value. */ +const OUTPUT_DELTA_META_KEYS = new Set(["terminal_output", "terminal_output_delta", "terminal_input", "mcp_output_delta"]); +const COMPARED_FIELDS = ["title", "kind", "status", "name", "content", "locations", "rawInput", "rawOutput"] as const; +/** The small fields that the adapter keeps for a finished tool call. */ +const FINISHED_FIELDS = new Set(["title", "kind", "status", "name"]); +const META_FIELD_PREFIX = "_meta."; +const MAX_FINISHED_TOOL_CALLS = 1024; + +/** + * Keeps the fields that the adapter reported for each open tool call in one session. + * + * ACP clients merge a `tool_call_update` into the stored tool call, and a present field replaces the stored value. + * So an update carries only the top-level fields that changed since the last report. + * ACP defines no merge for `_meta` keys. AIR replaces a stored `_meta` key with the key of an update, + * so only AIR also gets `_meta` without the unchanged keys, see `compareMeta`. + * The record of a tool call shrinks when the tool call reaches a terminal status. + * A bounded set of finished tool calls keeps the small fields, so that late output chunks can be dropped + * and a completion after a replayed start does not repeat the status. + */ +export class ToolCallReports { + private readonly openToolCalls = new Map>(); + private readonly finishedToolCalls = new Map>(); + + /** + * Also drop the unchanged `_meta` keys. The adapter sets it for AIR in `initialize`. + * Every other client gets the whole `_meta` of each report, as before the tool call contract. + */ + compareMeta = true; + + /** + * Returns the update to send, without the fields that did not change. + * Returns `null` when nothing is left to send. + */ + prepare(sessionId: string, update: UpdateSessionEvent): UpdateSessionEvent | null { + if (update.sessionUpdate !== "tool_call" && update.sessionUpdate !== "tool_call_update") { + return update; + } + const key = `${sessionId}\u0000${update.toolCallId}`; + const prepared = update.sessionUpdate === "tool_call" + ? this.recordStart(key, update) + : this.recordUpdate(key, update); + if (prepared !== null && (prepared.status === "completed" || prepared.status === "failed")) { + this.finish(key); + } + return prepared; + } + + /** + * Forgets the open tool calls of one session when its turn ends, or when a native child session ends. + * A tool call that never reached a terminal status would otherwise keep its fields until the session closes. + * A later update for a forgotten tool call carries every field again, which the client merges as usual. + */ + releaseOpen(sessionId: string): void { + const prefix = `${sessionId}\u0000`; + for (const key of [...this.openToolCalls.keys()]) { + if (key.startsWith(prefix)) this.openToolCalls.delete(key); + } + } + + /** Forgets the open record of one tool call. The next update of the tool call carries every field again. */ + forgetOpen(sessionId: string, toolCallId: string): void { + this.openToolCalls.delete(`${sessionId}\u0000${toolCallId}`); + } + + private recordStart(key: string, update: ToolCallReport): ToolCallReport { + this.finishedToolCalls.delete(key); + const fields = new Map(); + for (const [name, value] of reportedFields(update, this.compareMeta)) { + fields.set(name, value); + } + this.openToolCalls.set(key, fields); + return update; + } + + private recordUpdate(key: string, update: ToolCallReport): ToolCallReport | null { + const finished = this.finishedToolCalls.get(key); + if (finished !== undefined) { + const current = this.withoutLateOutput(update); + return current === null ? null : this.withoutUnchangedFields(current, finished, FINISHED_FIELDS); + } + const fields = this.openToolCalls.get(key) ?? new Map(); + this.openToolCalls.set(key, fields); + return this.withoutUnchangedFields(update, fields); + } + + /** Drops the fields whose value `fields` already has, and records the other fields, or only `recorded` ones. */ + private withoutUnchangedFields( + update: ToolCallReport, + fields: Map, + recorded?: Set, + ): ToolCallReport | null { + const prepared: Record = {...update}; + const meta = isRecord(update._meta) ? {...update._meta} : undefined; + for (const [name, value] of reportedFields(update, this.compareMeta)) { + if (fields.get(name) === value) { + if (name.startsWith(META_FIELD_PREFIX)) { + delete meta?.[name.slice(META_FIELD_PREFIX.length)]; + } else { + delete prepared[name]; + } + continue; + } + if (recorded === undefined || recorded.has(name)) fields.set(name, value); + } + if (meta !== undefined) { + if (Object.keys(meta).length > 0) { + prepared["_meta"] = meta; + } else { + delete prepared["_meta"]; + } + } + return hasPayload(prepared) ? prepared as ToolCallReport : null; + } + + private withoutLateOutput(update: ToolCallReport): ToolCallReport | null { + // An update with a status is a completion report, for example after a history replay + // started the tool call with its final status. Its output is not late. + if (update.status != null || !isRecord(update._meta)) { + return update; + } + const meta = {...update._meta}; + const dropped = Object.keys(meta).filter(name => OUTPUT_DELTA_META_KEYS.has(name)); + if (dropped.length === 0) { + return update; + } + for (const name of dropped) { + delete meta[name]; + } + logger.log("Dropped output for a finished tool call", {toolCallId: update.toolCallId, keys: dropped}); + const prepared: Record = {...update}; + if (Object.keys(meta).length > 0) { + prepared["_meta"] = meta; + } else { + delete prepared["_meta"]; + } + return hasPayload(prepared) ? prepared as ToolCallReport : null; + } + + private finish(key: string): void { + const fields = this.openToolCalls.get(key) ?? this.finishedToolCalls.get(key) ?? new Map(); + this.openToolCalls.delete(key); + this.finishedToolCalls.delete(key); + this.finishedToolCalls.set(key, new Map([...fields].filter(([name]) => FINISHED_FIELDS.has(name)))); + if (this.finishedToolCalls.size > MAX_FINISHED_TOOL_CALLS) { + const oldest = this.finishedToolCalls.keys().next().value; + if (oldest !== undefined) this.finishedToolCalls.delete(oldest); + } + } +} + +function reportedFields(update: ToolCallReport, withMeta: boolean): Array<[string, string]> { + const fields: Array<[string, string]> = []; + const record = update as Record; + for (const name of COMPARED_FIELDS) { + const value = record[name]; + if (value !== undefined) fields.push([name, JSON.stringify(value)]); + } + if (withMeta && isRecord(update._meta)) { + for (const [name, value] of Object.entries(update._meta)) { + if (value === undefined || OUTPUT_DELTA_META_KEYS.has(name)) continue; + fields.push([`${META_FIELD_PREFIX}${name}`, JSON.stringify(value)]); + } + } + return fields; +} + +function hasPayload(update: Record): boolean { + return Object.keys(update).some(name => name !== "sessionUpdate" && name !== "toolCallId"); +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts index d1e27aee..81c4147d 100644 --- a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts +++ b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts @@ -1031,7 +1031,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { const dump = mockFixture.getAcpConnectionDump([]); expect(dump).toContain('"sessionId": "thread-id"'); expect(dump).toContain('"sessionUpdate": "tool_call"'); - expect(dump).toContain('"toolCallId": "mcp_startup.broken-mcp"'); + expect(dump).toMatch(/"toolCallId": "mcp_startup\.broken-mcp\.[0-9a-f-]{36}"/); expect(dump).toContain('MCP server `broken-mcp` failed to start: boom'); }); @@ -2029,7 +2029,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { args: [expect.objectContaining({ update: { sessionUpdate: "session_info_update", - _meta: { + _meta: {jetbrains: {air: {version: 1, goal: { objective: "Ship the migration and keep tests green", status: "active", @@ -2040,7 +2040,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { updatedAt: 1710000100000, controlMethod: "_session/goal", }, - }, + }}}, }, })], })); @@ -2811,21 +2811,21 @@ describe('ACP server test', { timeout: 40_000 }, () => { expect.objectContaining({ args: [expect.objectContaining({ update: expect.objectContaining({ - _meta: {goal: expect.objectContaining({status: "paused"})}, + _meta: {jetbrains: {air: {version: 1, goal: expect.objectContaining({status: "paused"})}}}, }), })], }), expect.objectContaining({ args: [expect.objectContaining({ update: expect.objectContaining({ - _meta: {goal: expect.objectContaining({status: "active"})}, + _meta: {jetbrains: {air: {version: 1, goal: expect.objectContaining({status: "active"})}}}, }), })], }), expect.objectContaining({ args: [expect.objectContaining({ update: expect.objectContaining({ - _meta: {goal: null}, + _meta: {jetbrains: {air: {version: 1, goal: null}}}, }), })], }), @@ -3049,9 +3049,10 @@ describe('ACP server test', { timeout: 40_000 }, () => { && event.args[0]?.update?.sessionUpdate === "session_info_update" ); expect(goalUpdates).toHaveLength(1); - expect(goalUpdates[0]?.args[0]?.update?._meta).toEqual({ + expect(goalUpdates[0]?.args[0]?.update?._meta).toEqual({jetbrains: {air: { + version: 1, goal: expect.objectContaining({objective: "current", createdAt: 200000}), - }); + }}}); }); it('suppresses the first routed goal notification after cancellation marks the turn stale', async () => { diff --git a/src/__tests__/CodexACPAgent/agent-file-change-report.test.ts b/src/__tests__/CodexACPAgent/agent-file-change-report.test.ts index a9b8863b..6d180c71 100644 --- a/src/__tests__/CodexACPAgent/agent-file-change-report.test.ts +++ b/src/__tests__/CodexACPAgent/agent-file-change-report.test.ts @@ -92,10 +92,11 @@ describe("agent file-change report lifecycle", () => { vi.clearAllMocks(); }); - it("advertises the AIR capability", async () => { + it("advertises the AIR capability to AIR", async () => { const fixture = createCodexMockTestFixture(); const response = await fixture.getCodexAcpAgent().initialize({ protocolVersion: acp.PROTOCOL_VERSION, + clientCapabilities: {_meta: {jetbrains: {air: {version: 1, capabilities: []}}}}, }); expect(response._meta).toMatchObject({ diff --git a/src/__tests__/CodexACPAgent/approval-events.test.ts b/src/__tests__/CodexACPAgent/approval-events.test.ts index 138d5d92..b3e9c041 100644 --- a/src/__tests__/CodexACPAgent/approval-events.test.ts +++ b/src/__tests__/CodexACPAgent/approval-events.test.ts @@ -6,7 +6,11 @@ import type { FileChangeRequestApprovalParams, PermissionsRequestApprovalParams, } from "../../app-server/v2"; -import {createCodexMockTestFixture, createTestSessionState, type CodexMockTestFixture} from "../acp-test-utils"; +import { + createCodexMockTestFixture, + createTestSessionState, + type CodexMockTestFixture, +} from "../acp-test-utils"; import type {SessionState} from "../../CodexAcpServer"; import {AgentMode} from "../../AgentMode"; import {ApprovalOptionId} from "../../permissions/option-ids"; @@ -20,9 +24,15 @@ describe("Approval Events", () => { let fixture: CodexMockTestFixture; const sessionId = "test-session-id"; - beforeEach(() => { + beforeEach(async () => { fixture = createCodexMockTestFixture(); vi.clearAllMocks(); + // The permission presentation of these tests is the AIR shape. + await fixture.getCodexAcpAgent().initialize({ + protocolVersion: 1, + clientCapabilities: {_meta: {jetbrains: {air: {version: 1, capabilities: []}}}}, + }); + fixture.clearAcpConnectionDump(); }); function setupSessionWithPendingPrompt() { @@ -130,6 +140,46 @@ describe("Approval Events", () => { await finish(prompt); }); + it("keeps the status and the title of a started command and carries the raw input", async () => { + const prompt = setupSessionWithPendingPrompt(); + fixture.sendServerNotification({ + method: "item/started", + params: { + threadId: sessionId, + turnId: "turn-1", + startedAtMs: 0, + item: { + type: "commandExecution", + id: "command-item", + pluginId: null, + scriptPath: null, + command: "npm test", + cwd: "/workspace", + processId: null, + source: "agent", + status: "inProgress", + commandActions: [], + aggregatedOutput: null, + exitCode: null, + durationMs: null, + }, + }, + }); + await fixture.getCodexAcpClient().waitForSessionNotifications(sessionId); + fixture.clearAcpConnectionDump(); + fixture.setPermissionResponse({outcome: {outcome: "selected", optionId: ApprovalOptionId.AllowOnce}}); + + await fixture.sendServerRequest("item/commandExecution/requestApproval", commandParams(["accept", "cancel"])); + + // The request carries the title that the tool call already shows, so the client keeps it. + expect(permissionRequest().toolCall).toEqual({ + toolCallId: "command-item", + title: "npm test", + rawInput: {command: "npm test", cwd: "/workspace"}, + }); + await finish(prompt); + }); + it("emits an autonomous ACP v1 snapshot and maps explicit reject to decline", async () => { const prompt = setupSessionWithPendingPrompt(); fixture.setPermissionResponse({outcome: {outcome: "selected", optionId: ApprovalOptionId.Decline}}); @@ -170,15 +220,15 @@ describe("Approval Events", () => { kind: "reject_once", }, ], - _meta: {permission: { + _meta: {jetbrains: {air: {version: 1, permission: { version: 1, title: "Run command?", description: "Needed to verify the changes.", - }}, + }}}}, }); for (const option of permissionRequest().options) { - if (option._meta?.permission) { - expect(option._meta.permission).not.toHaveProperty("changes"); + if (option._meta?.jetbrains?.air?.permission) { + expect(option._meta.jetbrains.air.permission).not.toHaveProperty("changes"); } } expect(JSON.stringify(permissionRequest())).not.toContain("exact_command"); @@ -454,7 +504,7 @@ describe("Approval Events", () => { {name: "No, and tell Codex what to do differently"}, ], }); - expect(permissionRequest()._meta.permission.description).toBe("Needed to verify the changes."); + expect(permissionRequest()._meta.jetbrains.air.permission.description).toBe("Needed to verify the changes."); await finish(prompt); }); @@ -475,11 +525,11 @@ describe("Approval Events", () => { }), ); expect(response).toEqual({decision}); - expect(permissionRequest()._meta).toEqual({permission: { + expect(permissionRequest()._meta).toEqual({jetbrains: {air: {version: 1, permission: { version: 1, title: "Allow network access?", description: "Needed to verify the changes.", - }}); + }}}}); expect(permissionRequest().toolCall).toMatchObject({ title: `${protocol} network access to example.test`, content: [{type: "content", content: {type: "text", text: `${protocol} access to example.test`}}], @@ -613,19 +663,18 @@ describe("Approval Events", () => { ); expect(response).toEqual({decision: "acceptForSession"}); + // The file change already started, so the request keeps its status and kind. + expect(permissionRequest().toolCall).toEqual({ + toolCallId: "file-item", + title: "Editing files", + locations: [{path: "/workspace/a.ts"}, {path: "/workspace/b.ts"}], + }); expect(permissionRequest()).toMatchObject({ - toolCall: { - toolCallId: "file-item", - kind: "edit", - status: "pending", - title: "Edit files", - locations: [{path: "/workspace/a.ts"}, {path: "/workspace/b.ts"}], - }, - _meta: {permission: { + _meta: {jetbrains: {air: {version: 1, permission: { version: 1, title: "Make edits?", description: "Apply the generated edits.", - }}, + }}}}, }); expect(JSON.stringify(permissionRequest())).not.toContain("grantRoot"); expect(JSON.stringify(permissionRequest())).not.toContain("writes under"); @@ -709,11 +758,11 @@ describe("Approval Events", () => { 'write Codex filesystem scope {"kind":"project_roots","subpath":"build"}', ].join("\n")}}], }, - _meta: {permission: { + _meta: {jetbrains: {air: {version: 1, permission: { version: 1, title: "Grant permissions?", description: "The build needs generated output access.", - }}, + }}}}, }); expect(permissionRequest().options).toEqual([ { diff --git a/src/__tests__/CodexACPAgent/auth-error-events.test.ts b/src/__tests__/CodexACPAgent/auth-error-events.test.ts index 860740cc..1907bacd 100644 --- a/src/__tests__/CodexACPAgent/auth-error-events.test.ts +++ b/src/__tests__/CodexACPAgent/auth-error-events.test.ts @@ -628,7 +628,7 @@ describe("CodexEventHandler - auth error events", () => { updates.push(params.update); }), } as unknown as AcpClientConnection; - const handler = new CodexEventHandler(connection, state, false, true); + const handler = new CodexEventHandler(connection, state, true); await handler.handleSessionScopedNotification({ method: "error", params: { @@ -683,7 +683,7 @@ describe("CodexEventHandler - auth error events", () => { updates.push(params.update); }), } as unknown as AcpClientConnection; - const handler = new CodexEventHandler(connection, state, false, true, "test-epoch"); + const handler = new CodexEventHandler(connection, state, true, "test-epoch"); const retryError = (message: string) => ({ method: "error" as const, params: { @@ -822,6 +822,37 @@ describe("CodexEventHandler - auth error events", () => { ); }); +describe("CodexEventHandler - error text once", () => { + it("does not repeat the prompt error message as agent text", async () => { + const {result, updates} = await runPromptWithError(createTestSessionState({ + sessionId: "limited-session", + account: {type: "apiKey"}, + }), { + message: "Usage limits were exceeded", + codexErrorInfo: "usageLimitExceeded", + additionalDetails: null, + misalignment: null, + }); + + expect(result).toMatchObject({data: {message: "Usage limits were exceeded"}}); + expect(JSON.stringify(updates)).not.toContain("Usage limits were exceeded"); + }); + + it("keeps the message as agent text when the prompt error carries other details", async () => { + const {updates} = await runPromptWithError(createTestSessionState({ + sessionId: "details-session", + account: {type: "apiKey"}, + }), { + message: "Provider returned 401", + codexErrorInfo: {responseStreamDisconnected: {httpStatusCode: 401}}, + additionalDetails: "HTTP status 401", + misalignment: null, + }); + + expect(JSON.stringify(updates)).toContain("Provider returned 401"); + }); +}); + async function runPromptWithError( sessionState: SessionState, turnError: ErrorNotification["error"], diff --git a/src/__tests__/CodexACPAgent/auth-status.test.ts b/src/__tests__/CodexACPAgent/auth-status.test.ts index 49442051..54a240c3 100644 --- a/src/__tests__/CodexACPAgent/auth-status.test.ts +++ b/src/__tests__/CodexACPAgent/auth-status.test.ts @@ -421,7 +421,6 @@ describe("authStatus extension", () => { connection, createTestSessionState(), false, - false, "epoch", undefined, (notification: AccountUpdatedNotification) => received.push(notification), diff --git a/src/__tests__/CodexACPAgent/collab-agent-events.test.ts b/src/__tests__/CodexACPAgent/collab-agent-events.test.ts index 95ea2f99..5ea3ce8e 100644 --- a/src/__tests__/CodexACPAgent/collab-agent-events.test.ts +++ b/src/__tests__/CodexACPAgent/collab-agent-events.test.ts @@ -4,6 +4,7 @@ import type { SessionState } from "../../CodexAcpServer"; import { AgentMode } from "../../AgentMode"; import {ACPSessionConnection} from "../../ACPSessionConnection"; import {CodexSubagentEventRouter} from "../../subagents/CodexSubagentEventRouter"; +import {ToolCallReports} from "../../ToolCallReports"; import { createCodexMockTestFixture, createTestSessionState, @@ -121,8 +122,9 @@ describe("CodexEventHandler - collab agent tool call events", () => { .filter(update => update.toolCallId === "call-spawn-weather"); expect(collaborationUpdates).toMatchObject([ {sessionUpdate: "tool_call", title: "spawnAgent", status: "in_progress"}, - {sessionUpdate: "tool_call_update", title: "spawnAgent", status: "completed"}, + {sessionUpdate: "tool_call_update", status: "completed"}, ]); + expect(collaborationUpdates[1]).not.toHaveProperty("title"); mockFixture.setPermissionResponse({outcome: {outcome: "selected", optionId: "allow_once"}}); await mockFixture.sendServerRequest("item/commandExecution/requestApproval", { @@ -783,7 +785,7 @@ describe("CodexEventHandler - collab agent tool call events", () => { expect(updates.map(update => [update.sessionUpdate, update.toolCallId, update.title])).toEqual([ ["subagent_spawned", undefined, undefined], ["tool_call", "send-input", "sendInput"], - ["tool_call_update", "send-input", "sendInput"], + ["tool_call_update", "send-input", undefined], ["subagent_state_update", undefined, undefined], ]); }); @@ -1536,7 +1538,7 @@ describe("CodexEventHandler - collab agent tool call events", () => { expect(nestedSpawn?.args[0].sessionId).toBe("parent-thread:generation:2"); }); - it("bounds notifications buffered before a child is announced", async () => { + it("keeps every notification buffered before a child is announced", async () => { const router = new CodexSubagentEventRouter( sessionId, true, @@ -1590,8 +1592,8 @@ describe("CodexEventHandler - collab agent tool call events", () => { }); const buffered = router.takeBufferedNotifications(); - expect(buffered).toHaveLength(256); - expect((buffered[0]!.params as {itemId: string}).itemId).toBe("buffered-44"); + expect(buffered).toHaveLength(300); + expect((buffered[0]!.params as {itemId: string}).itemId).toBe("buffered-0"); }); it("publishes a terminal child state exactly once under concurrent completion", async () => { @@ -1638,4 +1640,94 @@ describe("CodexEventHandler - collab agent tool call events", () => { && event.args[0].update.sessionUpdate === "subagent_state_update"); expect(terminal).toHaveLength(1); }); + + describe("releases the open tool call records of a child session when the child ends", () => { + const spawn = (childThreadId: string): ServerNotification => ({ + method: "item/started", + params: { + threadId: sessionId, + turnId: "root-turn", + startedAtMs: 0, + item: { + type: "collabAgentToolCall", + id: `spawn-${childThreadId}`, + tool: "spawnAgent", + status: "inProgress", + senderThreadId: sessionId, + receiverThreadIds: [childThreadId], + prompt: "Task", + model: null, + reasoningEffort: null, + agentsStates: {[childThreadId]: {status: "running", message: null}}, + }, + }, + }); + const activity = (childThreadId: string): ServerNotification => ({ + method: "item/started", + params: { + threadId: sessionId, + turnId: "root-turn", + startedAtMs: 0, + item: { + type: "subAgentActivity", + id: `activity-${childThreadId}`, + kind: "started", + agentThreadId: childThreadId, + agentPath: `/root/${childThreadId}`, + }, + }, + }); + const turnCompleted = (threadId: string, status: "completed" | "failed"): ServerNotification => ({ + method: "turn/completed", + params: { + threadId, + turn: { + id: `${threadId}-turn`, + items: [], + itemsView: "notLoaded", + status, + error: null, + startedAt: null, + completedAt: null, + durationMs: null, + }, + }, + }); + const report = {sessionUpdate: "tool_call_update" as const, toolCallId: "child-tool", title: "npm test"}; + + async function childRouter(reports: ToolCallReports, childThreadId: string, materialize: boolean) { + const router = new CodexSubagentEventRouter( + sessionId, + true, + new ACPSessionConnection(mockFixture.getAcpConnection(), sessionId), + childSessionId => reports.releaseOpen(childSessionId), + ); + await router.handle(spawn(childThreadId)); + if (materialize) await router.handle(activity(childThreadId)); + reports.prepare(childThreadId, report); + expect(reports.prepare(childThreadId, report)).toBeNull(); + return router; + } + + it("on a child turn/completed", async () => { + const reports = new ToolCallReports(); + const router = await childRouter(reports, "child-a", true); + await router.handle(turnCompleted("child-a", "completed")); + expect(reports.prepare("child-a", report)).toEqual(report); + }); + + it("when the root turn fails and the adapter finishes the outstanding children", async () => { + const reports = new ToolCallReports(); + const router = await childRouter(reports, "child-b", true); + await router.finishOutstanding("failed"); + expect(reports.prepare("child-b", report)).toEqual(report); + }); + + it("when a pending child ends before it has a session", async () => { + const reports = new ToolCallReports(); + const router = await childRouter(reports, "child-c", false); + await router.handle(turnCompleted("child-c", "failed")); + expect(reports.prepare("child-c", report)).toEqual(report); + }); + }); }); diff --git a/src/__tests__/CodexACPAgent/command-output-once.test.ts b/src/__tests__/CodexACPAgent/command-output-once.test.ts new file mode 100644 index 00000000..fcca873b --- /dev/null +++ b/src/__tests__/CodexACPAgent/command-output-once.test.ts @@ -0,0 +1,254 @@ +import {describe, expect, it} from "vitest"; +import type {ServerNotification} from "../../app-server"; +import type {ThreadItem} from "../../app-server/v2"; +import {AcpToolCallRenderer} from "../../tool-calls/AcpToolCallRenderer"; +import {ClientCapabilities} from "../../tool-calls/ClientCapabilities"; +import {CommandReporter} from "../../tool-calls/reporters/CommandReporter"; +import {McpStartupReporter} from "../../tool-calls/reporters/McpStartupReporter"; +import {parseResponseItemHistoryFallback} from "../../ResponseItemHistoryFallback"; +import {createCodexMockTestFixture, createTestSessionState, setupPromptAndSendNotifications} from "../acp-test-utils"; + +type CommandItem = ThreadItem & {type: "commandExecution"}; + +function command(overrides: Partial = {}): CommandItem { + return { + type: "commandExecution", + id: "cmd-1", + pluginId: null, + scriptPath: null, + command: "ls", + cwd: "/workspace", + processId: null, + source: "agent", + status: "completed", + commandActions: [], + aggregatedOutput: "a.txt\nb.txt\n", + exitCode: 0, + durationMs: 1, + ...overrides, + }; +} + +function occurrences(value: unknown, text: string): number { + const serialized = typeof value === "string" ? value : JSON.stringify(value); + return serialized.split(JSON.stringify(text).slice(1, -1)).length - 1; +} + +const DELTA_CLIENT = ClientCapabilities.DEFAULT.with({airClient: true, terminalOutputDelta: true}); +const ZED_CLIENT = ClientCapabilities.DEFAULT.with({terminalOutput: true}); + +function completion(item: CommandItem, capabilities: ClientCapabilities) { + const reporter = new CommandReporter(); + reporter.started({...item, status: "inProgress"}); + return new AcpToolCallRenderer(capabilities).render(reporter.completed(item)); +} + +describe("command output is sent once", () => { + it("sends the output as terminal_output_delta to AIR", () => { + expect(completion(command(), DELTA_CLIENT)).toEqual({ + sessionUpdate: "tool_call_update", + toolCallId: "cmd-1", + status: "completed", + _meta: { + terminal_output_delta: {data: "a.txt\nb.txt\n", terminal_id: "cmd-1"}, + terminal_exit: {exit_code: 0, signal: null, terminal_id: "cmd-1"}, + }, + }); + }); + + it("keeps the Zed terminal conventions for a client without output deltas", () => { + const renderer = new AcpToolCallRenderer(ZED_CLIENT); + const reporter = new CommandReporter(); + const start = renderer.render(reporter.started(command({status: "inProgress", aggregatedOutput: null, exitCode: null}))); + const chunk = renderer.render(reporter.outputDelta("cmd-1", "a.txt\nb.txt\n")!); + const end = renderer.render(reporter.completed(command())); + + expect(start).toMatchObject({ + content: [{type: "terminal", terminalId: "cmd-1"}], + _meta: {terminal_info: {cwd: "/workspace", terminal_id: "cmd-1"}}, + }); + expect(chunk).toEqual({ + sessionUpdate: "tool_call_update", + toolCallId: "cmd-1", + _meta: {terminal_output: {data: "a.txt\nb.txt\n", terminal_id: "cmd-1"}}, + }); + expect(end).toEqual({ + sessionUpdate: "tool_call_update", + toolCallId: "cmd-1", + status: "completed", + rawOutput: {formatted_output: "a.txt\nb.txt\n", exit_code: 0}, + _meta: {terminal_exit: {exit_code: 0, signal: null, terminal_id: "cmd-1"}}, + }); + }); + + it("sends the output of a read command once in the content to AIR", () => { + const update = completion(command({ + commandActions: [{type: "read", command: "cat a.txt", name: "a.txt", path: "/workspace/a.txt"}], + }), DELTA_CLIENT); + + expect(update).toEqual({ + sessionUpdate: "tool_call_update", + toolCallId: "cmd-1", + status: "completed", + content: [{type: "content", content: {type: "text", text: "a.txt\nb.txt\n"}}], + }); + }); + + it("sends the output of a read command to Zed in rawOutput, as before the AIR contract", () => { + const update = completion(command({ + commandActions: [{type: "read", command: "cat a.txt", name: "a.txt", path: "/workspace/a.txt"}], + }), ZED_CLIENT); + + expect(update).toEqual({ + sessionUpdate: "tool_call_update", + toolCallId: "cmd-1", + status: "completed", + rawOutput: {formatted_output: "a.txt\nb.txt\n", exit_code: 0}, + }); + }); + + it("sends stdin as terminal_input and not as output", () => { + const renderer = new AcpToolCallRenderer(DELTA_CLIENT); + const reporter = new CommandReporter(); + reporter.started(command({status: "inProgress"})); + + expect(renderer.render(reporter.terminalInput("cmd-1", "yes")!)).toEqual({ + sessionUpdate: "tool_call_update", + toolCallId: "cmd-1", + _meta: {terminal_input: {data: "yes", terminal_id: "cmd-1"}}, + }); + }); + + it("sends the live output only as streamed deltas", async () => { + const fixture = createCodexMockTestFixture(); + const sessionId = "command-once"; + await setupPromptAndSendNotifications( + fixture, + sessionId, + createTestSessionState({sessionId, clientCapabilities: DELTA_CLIENT}), + liveCommand(sessionId), + ); + + const dump = fixture.getAcpConnectionDump([]); + expect(occurrences(dump, "a.txt\nb.txt\n")).toBe(1); + expect(dump).toContain("terminal_output_delta"); + expect(dump).not.toContain("formatted_output"); + expect(dump).not.toContain("exit_code\": 0,\n \"formatted"); + }); + + it("sends the live output of Zed as terminal_output chunks and the whole output in rawOutput", async () => { + const fixture = createCodexMockTestFixture(); + const sessionId = "command-once-zed"; + await setupPromptAndSendNotifications( + fixture, + sessionId, + createTestSessionState({sessionId, clientCapabilities: ZED_CLIENT}), + liveCommand(sessionId), + ); + + const dump = fixture.getAcpConnectionDump([]); + expect(occurrences(dump, "a.txt\nb.txt\n")).toBe(2); + expect(dump).toContain("\"terminal_output\""); + expect(dump).toContain("terminal_exit"); + expect(dump).toContain("formatted_output"); + expect(dump).not.toContain("terminal_output_delta"); + }); + + it("sends the streamed output of a read command once in the content", async () => { + const fixture = createCodexMockTestFixture(); + const sessionId = "read-once"; + const read = {commandActions: [{type: "read" as const, command: "cat a.txt", name: "a.txt", path: "/workspace/a.txt"}]}; + await setupPromptAndSendNotifications( + fixture, + sessionId, + createTestSessionState({sessionId}), + liveCommand(sessionId, read), + ); + + const dump = fixture.getAcpConnectionDump([]); + expect(occurrences(dump, "a.txt\nb.txt\n")).toBe(1); + expect(dump).not.toContain("terminal_output_delta"); + expect(dump).toContain("\"content\""); + }); + + it("replays fallback history output only in the terminal channel for AIR, and also in rawOutput for Zed", () => { + const jsonl = [ + {type: "response_item", payload: {type: "function_call", name: "exec_command", call_id: "call-1", arguments: JSON.stringify({cmd: "npm test", workdir: "/workspace", yield_time_ms: 1000})}}, + {type: "response_item", payload: {type: "function_call_output", call_id: "call-1", output: "Process exited with code 0\nOutput:\nfallback-output\n"}}, + ].map(line => JSON.stringify(line)).join("\n"); + + const air = parseResponseItemHistoryFallback(jsonl, DELTA_CLIENT) + ?.find(update => update.sessionUpdate === "tool_call_update"); + expect(air).not.toHaveProperty("rawOutput"); + expect(occurrences(air, "fallback-output")).toBe(1); + expect(air).toHaveProperty("_meta.terminal_exit"); + + const zed = parseResponseItemHistoryFallback(jsonl, ZED_CLIENT) + ?.find(update => update.sessionUpdate === "tool_call_update"); + expect(occurrences(zed, "fallback-output")).toBe(2); + expect(zed).toHaveProperty("rawOutput.formatted_output"); + expect(zed).toHaveProperty("_meta.terminal_output"); + expect(zed).toHaveProperty("_meta.terminal_exit"); + }); +}); + +function liveCommand(sessionId: string, overrides: Partial = {}): ServerNotification[] { + return [ + { + method: "item/started", + params: {threadId: sessionId, turnId: "turn-1", startedAtMs: 0, item: command({status: "inProgress", aggregatedOutput: null, exitCode: null, ...overrides})}, + }, + { + method: "item/commandExecution/outputDelta", + params: {threadId: sessionId, turnId: "turn-1", itemId: "cmd-1", delta: "a.txt\nb.txt\n"}, + }, + { + method: "item/completed", + params: {threadId: sessionId, turnId: "turn-1", completedAtMs: 1, item: command(overrides)}, + }, + ]; +} + +describe("late output deltas", () => { + it("drops command and MCP output that arrives after completion", async () => { + const fixture = createCodexMockTestFixture(); + const sessionId = "late-output"; + const notifications: ServerNotification[] = [ + { + method: "item/started", + params: {threadId: sessionId, turnId: "turn-1", startedAtMs: 0, item: command({status: "inProgress", aggregatedOutput: null, exitCode: null})}, + }, + { + method: "item/completed", + params: {threadId: sessionId, turnId: "turn-1", completedAtMs: 1, item: command()}, + }, + { + method: "item/commandExecution/outputDelta", + params: {threadId: sessionId, turnId: "turn-1", itemId: "cmd-1", delta: "late-command-output"}, + }, + { + method: "item/mcpToolCall/progress", + params: {threadId: sessionId, turnId: "turn-1", itemId: "cmd-1", message: "late-mcp-output"}, + }, + ]; + + await setupPromptAndSendNotifications(fixture, sessionId, createTestSessionState({sessionId}), notifications); + + const dump = fixture.getAcpConnectionDump([]); + expect(dump).not.toContain("late-command-output"); + expect(dump).not.toContain("late-mcp-output"); + }); +}); + +describe("MCP startup tool call ids", () => { + it("gives each startup report a unique tool call id", () => { + const event = {ready: [], failed: [{server: "broken", error: "boom", failureReason: null}], cancelled: ["slow"]}; + const first = McpStartupReporter.failures(event as never); + const second = McpStartupReporter.failures(event as never); + const ids = [...first, ...second].map(facts => facts.toolCallId); + + expect(new Set(ids).size).toBe(4); + expect(ids[0]).toMatch(/^mcp_startup\.broken\./); + expect(ids[1]).toMatch(/^mcp_startup\.slow\./); + }); +}); diff --git a/src/__tests__/CodexACPAgent/data/agent-message-phases.json b/src/__tests__/CodexACPAgent/data/agent-message-phases.json index 8f1a4b1e..606aabe6 100644 --- a/src/__tests__/CodexACPAgent/data/agent-message-phases.json +++ b/src/__tests__/CodexACPAgent/data/agent-message-phases.json @@ -11,8 +11,11 @@ "text": "Checking the relevant event mapping." }, "_meta": { - "codex": { - "phase": "commentary" + "jetbrains": { + "air": { + "version": 1, + "phase": "commentary" + } } } } @@ -32,8 +35,11 @@ "text": "Yes, here is the answer." }, "_meta": { - "codex": { - "phase": "final_answer" + "jetbrains": { + "air": { + "version": 1, + "phase": "final_answer" + } } } } diff --git a/src/__tests__/CodexACPAgent/data/available-commands-build-in.json b/src/__tests__/CodexACPAgent/data/available-commands-build-in.json index c7eeaab5..d611fd30 100644 --- a/src/__tests__/CodexACPAgent/data/available-commands-build-in.json +++ b/src/__tests__/CodexACPAgent/data/available-commands-build-in.json @@ -11,12 +11,17 @@ "description": "Turn plan mode on.", "input": null, "_meta": { - "commandAction": { - "kind": "setConfigOption", - "configId": "collaboration_mode", - "value": "plan", - "resetValue": "default", - "presentation": "state" + "jetbrains": { + "air": { + "version": 1, + "commandAction": { + "kind": "setConfigOption", + "configId": "collaboration_mode", + "value": "plan", + "resetValue": "default", + "presentation": "state" + } + } } } }, @@ -68,9 +73,14 @@ "hint": "[|clear|pause|resume]" }, "_meta": { - "commandAction": { - "kind": "prefixPrompt", - "presentation": "state" + "jetbrains": { + "air": { + "version": 1, + "commandAction": { + "kind": "prefixPrompt", + "presentation": "state" + } + } } } }, diff --git a/src/__tests__/CodexACPAgent/data/available-commands-skills.json b/src/__tests__/CodexACPAgent/data/available-commands-skills.json index cd01211e..fbc66b5c 100644 --- a/src/__tests__/CodexACPAgent/data/available-commands-skills.json +++ b/src/__tests__/CodexACPAgent/data/available-commands-skills.json @@ -11,12 +11,17 @@ "description": "Turn plan mode on.", "input": null, "_meta": { - "commandAction": { - "kind": "setConfigOption", - "configId": "collaboration_mode", - "value": "plan", - "resetValue": "default", - "presentation": "state" + "jetbrains": { + "air": { + "version": 1, + "commandAction": { + "kind": "setConfigOption", + "configId": "collaboration_mode", + "value": "plan", + "resetValue": "default", + "presentation": "state" + } + } } } }, @@ -68,9 +73,14 @@ "hint": "[|clear|pause|resume]" }, "_meta": { - "commandAction": { - "kind": "prefixPrompt", - "presentation": "state" + "jetbrains": { + "air": { + "version": 1, + "commandAction": { + "kind": "prefixPrompt", + "presentation": "state" + } + } } } }, diff --git a/src/__tests__/CodexACPAgent/data/command-list-files-with-path.json b/src/__tests__/CodexACPAgent/data/command-list-files-with-path.json index 27e19433..0063a4db 100644 --- a/src/__tests__/CodexACPAgent/data/command-list-files-with-path.json +++ b/src/__tests__/CodexACPAgent/data/command-list-files-with-path.json @@ -6,9 +6,9 @@ "update": { "sessionUpdate": "tool_call", "toolCallId": "command-list-path", - "status": "in_progress", "kind": "read", - "title": "List files in '/test/project'" + "title": "List files in '/test/project'", + "status": "in_progress" } } ] diff --git a/src/__tests__/CodexACPAgent/data/command-list-files-without-path.json b/src/__tests__/CodexACPAgent/data/command-list-files-without-path.json index c36a39e1..55296304 100644 --- a/src/__tests__/CodexACPAgent/data/command-list-files-without-path.json +++ b/src/__tests__/CodexACPAgent/data/command-list-files-without-path.json @@ -6,9 +6,9 @@ "update": { "sessionUpdate": "tool_call", "toolCallId": "command-list-no-path", - "status": "completed", "kind": "read", - "title": "List files" + "title": "List files", + "status": "completed" } } ] diff --git a/src/__tests__/CodexACPAgent/data/command-read-file-with-path.json b/src/__tests__/CodexACPAgent/data/command-read-file-with-path.json index 0368d6f2..2f4891da 100644 --- a/src/__tests__/CodexACPAgent/data/command-read-file-with-path.json +++ b/src/__tests__/CodexACPAgent/data/command-read-file-with-path.json @@ -6,9 +6,9 @@ "update": { "sessionUpdate": "tool_call", "toolCallId": "command-read-file", - "status": "in_progress", "kind": "read", "title": "Read file '/test/project/src/index.ts'", + "status": "in_progress", "locations": [ { "path": "/test/project/src/index.ts" diff --git a/src/__tests__/CodexACPAgent/data/command-search-no-query-no-path.json b/src/__tests__/CodexACPAgent/data/command-search-no-query-no-path.json index 326d71eb..521c5e4a 100644 --- a/src/__tests__/CodexACPAgent/data/command-search-no-query-no-path.json +++ b/src/__tests__/CodexACPAgent/data/command-search-no-query-no-path.json @@ -6,9 +6,9 @@ "update": { "sessionUpdate": "tool_call", "toolCallId": "command-search-no-query-no-path", - "status": "in_progress", "kind": "search", - "title": "Search" + "title": "Search", + "status": "in_progress" } } ] diff --git a/src/__tests__/CodexACPAgent/data/command-search-with-path-only.json b/src/__tests__/CodexACPAgent/data/command-search-with-path-only.json index fb4de620..c133bd33 100644 --- a/src/__tests__/CodexACPAgent/data/command-search-with-path-only.json +++ b/src/__tests__/CodexACPAgent/data/command-search-with-path-only.json @@ -6,9 +6,9 @@ "update": { "sessionUpdate": "tool_call", "toolCallId": "command-search-path-only", - "status": "in_progress", "kind": "search", - "title": "Search in '*service*'" + "title": "Search in '*service*'", + "status": "in_progress" } } ] diff --git a/src/__tests__/CodexACPAgent/data/command-search-with-query-and-path.json b/src/__tests__/CodexACPAgent/data/command-search-with-query-and-path.json index 98d72396..e5b8a2d9 100644 --- a/src/__tests__/CodexACPAgent/data/command-search-with-query-and-path.json +++ b/src/__tests__/CodexACPAgent/data/command-search-with-query-and-path.json @@ -6,9 +6,9 @@ "update": { "sessionUpdate": "tool_call", "toolCallId": "command-search-query-path", - "status": "in_progress", "kind": "search", - "title": "Search for 'Service' in src" + "title": "Search for 'Service' in src", + "status": "in_progress" } } ] diff --git a/src/__tests__/CodexACPAgent/data/command-search-with-query-only.json b/src/__tests__/CodexACPAgent/data/command-search-with-query-only.json index 0577ed8b..853ea18d 100644 --- a/src/__tests__/CodexACPAgent/data/command-search-with-query-only.json +++ b/src/__tests__/CodexACPAgent/data/command-search-with-query-only.json @@ -6,9 +6,9 @@ "update": { "sessionUpdate": "tool_call", "toolCallId": "command-search-query-only", - "status": "in_progress", "kind": "search", - "title": "Search for 'Service'" + "title": "Search for 'Service'", + "status": "in_progress" } } ] diff --git a/src/__tests__/CodexACPAgent/data/context-compaction-lifecycle.json b/src/__tests__/CodexACPAgent/data/context-compaction-lifecycle.json index 53d145ba..5fa3d6b1 100644 --- a/src/__tests__/CodexACPAgent/data/context-compaction-lifecycle.json +++ b/src/__tests__/CodexACPAgent/data/context-compaction-lifecycle.json @@ -10,8 +10,13 @@ "title": "Compact conversation", "status": "in_progress", "_meta": { - "contextCompaction": { - "version": 1 + "jetbrains": { + "air": { + "version": 1, + "contextCompaction": { + "version": 1 + } + } } } } @@ -26,13 +31,7 @@ "update": { "sessionUpdate": "tool_call_update", "toolCallId": "context-compaction-id", - "title": "Compact conversation", - "status": "completed", - "_meta": { - "contextCompaction": { - "version": 1 - } - } + "status": "completed" } } ] diff --git a/src/__tests__/CodexACPAgent/data/dynamic-tool-completed.json b/src/__tests__/CodexACPAgent/data/dynamic-tool-completed.json index b991960a..3df92c68 100644 --- a/src/__tests__/CodexACPAgent/data/dynamic-tool-completed.json +++ b/src/__tests__/CodexACPAgent/data/dynamic-tool-completed.json @@ -7,7 +7,16 @@ "sessionUpdate": "tool_call_update", "toolCallId": "dyn-tool-123", "name": "list_apps", - "status": "completed" + "status": "completed", + "content": [ + { + "type": "content", + "content": { + "type": "text", + "text": "Done" + } + } + ] } } ] diff --git a/src/__tests__/CodexACPAgent/data/dynamic-tool-in-progress.json b/src/__tests__/CodexACPAgent/data/dynamic-tool-in-progress.json index ca073327..8ca70559 100644 --- a/src/__tests__/CodexACPAgent/data/dynamic-tool-in-progress.json +++ b/src/__tests__/CodexACPAgent/data/dynamic-tool-in-progress.json @@ -6,6 +6,7 @@ "update": { "sessionUpdate": "tool_call", "toolCallId": "dyn-call-id", + "name": "list_apps", "kind": "execute", "title": "list_apps", "status": "in_progress", @@ -13,8 +14,7 @@ "arguments": { "includeDisabled": false } - }, - "name": "list_apps" + } } } ] diff --git a/src/__tests__/CodexACPAgent/data/elicitation-tool-approval-all-persist.json b/src/__tests__/CodexACPAgent/data/elicitation-tool-approval-all-persist.json index 30dffbb8..d6524f5e 100644 --- a/src/__tests__/CodexACPAgent/data/elicitation-tool-approval-all-persist.json +++ b/src/__tests__/CodexACPAgent/data/elicitation-tool-approval-all-persist.json @@ -8,6 +8,14 @@ "kind": "execute", "status": "pending", "title": "MCP tool call approval", + "rawInput": { + "serverName": "tool-server", + "description": "Allow tool call?", + "schema": { + "type": "object", + "properties": {} + } + }, "content": [ { "type": "content", @@ -16,41 +24,29 @@ "text": "Allow tool call?" } } - ], - "rawInput": { - "serverName": "tool-server", - "description": "Allow tool call?", - "schema": { - "type": "object", - "properties": {} - } - } + ] }, "_meta": "_meta", "options": [ { "optionId": "allow_once", "name": "Allow", - "kind": "allow_once", - "_meta": "_meta" + "kind": "allow_once" }, { "optionId": "allow_session", "name": "Allow for this session", - "kind": "allow_always", - "_meta": "_meta" + "kind": "allow_always" }, { "optionId": "allow_always", "name": "Always allow", - "kind": "allow_always", - "_meta": "_meta" + "kind": "allow_always" }, { "optionId": "cancel", "name": "Cancel", - "kind": "reject_once", - "_meta": "_meta" + "kind": "reject_once" } ] } @@ -65,16 +61,6 @@ "sessionUpdate": "tool_call_update", "toolCallId": "elicitation:test-session-id:tool-server:1", "status": "completed", - "title": "MCP tool call approval", - "content": [ - { - "type": "content", - "content": { - "type": "text", - "text": "Allow tool call?" - } - } - ], "rawOutput": { "action": "accept" } diff --git a/src/__tests__/CodexACPAgent/data/elicitation-tool-approval-no-persist.json b/src/__tests__/CodexACPAgent/data/elicitation-tool-approval-no-persist.json index 9cb4e04a..494368e6 100644 --- a/src/__tests__/CodexACPAgent/data/elicitation-tool-approval-no-persist.json +++ b/src/__tests__/CodexACPAgent/data/elicitation-tool-approval-no-persist.json @@ -8,6 +8,14 @@ "kind": "execute", "status": "pending", "title": "MCP tool call approval", + "rawInput": { + "serverName": "tool-server", + "description": "Allow tool call?", + "schema": { + "type": "object", + "properties": {} + } + }, "content": [ { "type": "content", @@ -16,29 +24,19 @@ "text": "Allow tool call?" } } - ], - "rawInput": { - "serverName": "tool-server", - "description": "Allow tool call?", - "schema": { - "type": "object", - "properties": {} - } - } + ] }, "_meta": "_meta", "options": [ { "optionId": "allow_once", "name": "Allow", - "kind": "allow_once", - "_meta": "_meta" + "kind": "allow_once" }, { "optionId": "cancel", "name": "Cancel", - "kind": "reject_once", - "_meta": "_meta" + "kind": "reject_once" } ] } @@ -53,16 +51,6 @@ "sessionUpdate": "tool_call_update", "toolCallId": "elicitation:test-session-id:tool-server:1", "status": "completed", - "title": "MCP tool call approval", - "content": [ - { - "type": "content", - "content": { - "type": "text", - "text": "Allow tool call?" - } - } - ], "rawOutput": { "action": "accept" } diff --git a/src/__tests__/CodexACPAgent/data/elicitation-tool-approval-session-only.json b/src/__tests__/CodexACPAgent/data/elicitation-tool-approval-session-only.json index 3f509e7f..5a27d67b 100644 --- a/src/__tests__/CodexACPAgent/data/elicitation-tool-approval-session-only.json +++ b/src/__tests__/CodexACPAgent/data/elicitation-tool-approval-session-only.json @@ -8,6 +8,14 @@ "kind": "execute", "status": "pending", "title": "MCP tool call approval", + "rawInput": { + "serverName": "tool-server", + "description": "Allow tool call?", + "schema": { + "type": "object", + "properties": {} + } + }, "content": [ { "type": "content", @@ -16,35 +24,24 @@ "text": "Allow tool call?" } } - ], - "rawInput": { - "serverName": "tool-server", - "description": "Allow tool call?", - "schema": { - "type": "object", - "properties": {} - } - } + ] }, "_meta": "_meta", "options": [ { "optionId": "allow_once", "name": "Allow", - "kind": "allow_once", - "_meta": "_meta" + "kind": "allow_once" }, { "optionId": "allow_session", "name": "Allow for this session", - "kind": "allow_always", - "_meta": "_meta" + "kind": "allow_always" }, { "optionId": "cancel", "name": "Cancel", - "kind": "reject_once", - "_meta": "_meta" + "kind": "reject_once" } ] } @@ -59,16 +56,6 @@ "sessionUpdate": "tool_call_update", "toolCallId": "elicitation:test-session-id:tool-server:1", "status": "completed", - "title": "MCP tool call approval", - "content": [ - { - "type": "content", - "content": { - "type": "text", - "text": "Allow tool call?" - } - } - ], "rawOutput": { "action": "accept" } diff --git a/src/__tests__/CodexACPAgent/data/elicitation-url-accept.json b/src/__tests__/CodexACPAgent/data/elicitation-url-accept.json index 195f44d9..93ecd53c 100644 --- a/src/__tests__/CodexACPAgent/data/elicitation-url-accept.json +++ b/src/__tests__/CodexACPAgent/data/elicitation-url-accept.json @@ -8,6 +8,11 @@ "kind": "fetch", "status": "pending", "title": "MCP server requests to open a URL", + "rawInput": { + "serverName": "auth-server", + "description": "Please authorize access to your GitHub account", + "url": "https://example.com/authorize?id=elicit-789" + }, "content": [ { "type": "content", @@ -16,31 +21,23 @@ "text": "Please authorize access to your GitHub account" } } - ], - "rawInput": { - "serverName": "auth-server", - "description": "Please authorize access to your GitHub account", - "url": "https://example.com/authorize?id=elicit-789" - } + ] }, "options": [ { "optionId": "accept", "name": "Allow", - "kind": "allow_once", - "_meta": "_meta" + "kind": "allow_once" }, { "optionId": "decline", "name": "Deny", - "kind": "reject_once", - "_meta": "_meta" + "kind": "reject_once" }, { "optionId": "cancel", "name": "Cancel", - "kind": "reject_once", - "_meta": "_meta" + "kind": "reject_once" } ] } @@ -55,16 +52,6 @@ "sessionUpdate": "tool_call_update", "toolCallId": "elicitation-elicit-789", "status": "completed", - "title": "MCP server requests to open a URL", - "content": [ - { - "type": "content", - "content": { - "type": "text", - "text": "Please authorize access to your GitHub account" - } - } - ], "rawOutput": { "action": "accept" } diff --git a/src/__tests__/CodexACPAgent/data/file-change-add-multiple-files.json b/src/__tests__/CodexACPAgent/data/file-change-add-multiple-files.json index 41b58b87..1798ccc9 100644 --- a/src/__tests__/CodexACPAgent/data/file-change-add-multiple-files.json +++ b/src/__tests__/CodexACPAgent/data/file-change-add-multiple-files.json @@ -6,8 +6,8 @@ "update": { "sessionUpdate": "tool_call", "toolCallId": "file-change-2", - "title": "Editing files", "kind": "edit", + "title": "Editing files", "status": "completed", "content": [ { diff --git a/src/__tests__/CodexACPAgent/data/file-change-add-new-file.json b/src/__tests__/CodexACPAgent/data/file-change-add-new-file.json index 4bf59ca9..26a36df8 100644 --- a/src/__tests__/CodexACPAgent/data/file-change-add-new-file.json +++ b/src/__tests__/CodexACPAgent/data/file-change-add-new-file.json @@ -6,8 +6,8 @@ "update": { "sessionUpdate": "tool_call", "toolCallId": "file-change-1", - "title": "Editing files", "kind": "edit", + "title": "Editing files", "status": "completed", "content": [ { diff --git a/src/__tests__/CodexACPAgent/data/file-change-add-raw-content.json b/src/__tests__/CodexACPAgent/data/file-change-add-raw-content.json index 31f18f5a..c139a287 100644 --- a/src/__tests__/CodexACPAgent/data/file-change-add-raw-content.json +++ b/src/__tests__/CodexACPAgent/data/file-change-add-raw-content.json @@ -6,8 +6,8 @@ "update": { "sessionUpdate": "tool_call", "toolCallId": "file-change-raw", - "title": "Editing files", "kind": "edit", + "title": "Editing files", "status": "completed", "content": [ { diff --git a/src/__tests__/CodexACPAgent/data/file-change-delete-file.json b/src/__tests__/CodexACPAgent/data/file-change-delete-file.json index ea6dd487..d38992a4 100644 --- a/src/__tests__/CodexACPAgent/data/file-change-delete-file.json +++ b/src/__tests__/CodexACPAgent/data/file-change-delete-file.json @@ -6,8 +6,8 @@ "update": { "sessionUpdate": "tool_call", "toolCallId": "file-change-3", - "title": "Editing files", "kind": "edit", + "title": "Editing files", "status": "completed", "content": [ { diff --git a/src/__tests__/CodexACPAgent/data/file-change-delete-raw-content.json b/src/__tests__/CodexACPAgent/data/file-change-delete-raw-content.json index 60f83da7..67a1a94d 100644 --- a/src/__tests__/CodexACPAgent/data/file-change-delete-raw-content.json +++ b/src/__tests__/CodexACPAgent/data/file-change-delete-raw-content.json @@ -6,8 +6,8 @@ "update": { "sessionUpdate": "tool_call", "toolCallId": "file-delete-raw", - "title": "Editing files", "kind": "edit", + "title": "Editing files", "status": "completed", "content": [ { diff --git a/src/__tests__/CodexACPAgent/data/guardian-approval-review-completed-without-start.json b/src/__tests__/CodexACPAgent/data/guardian-approval-review-completed-without-start.json index d27929c8..f59159fd 100644 --- a/src/__tests__/CodexACPAgent/data/guardian-approval-review-completed-without-start.json +++ b/src/__tests__/CodexACPAgent/data/guardian-approval-review-completed-without-start.json @@ -14,24 +14,18 @@ "type": "content", "content": { "type": "text", - "text": "Status: Denied\nAction: network access to api.example.com\nRisk: high\nAuthorization: low\nRationale: The network target is not permitted." + "text": "Action: network access to api.example.com" + } + }, + { + "type": "content", + "content": { + "type": "text", + "text": "Status: Denied\nRisk: high\nAuthorization: low\nRationale: The network target is not permitted." } } ], "rawInput": { - "threadId": "test-session-id", - "turnId": "turn-1", - "startedAtMs": 1000, - "completedAtMs": 1800, - "reviewId": "review-orphaned", - "targetItemId": null, - "decisionSource": "agent", - "review": { - "status": "denied", - "riskLevel": "high", - "userAuthorization": "low", - "rationale": "The network target is not permitted." - }, "action": { "type": "networkAccess", "target": "", diff --git a/src/__tests__/CodexACPAgent/data/guardian-approval-review-flow.json b/src/__tests__/CodexACPAgent/data/guardian-approval-review-flow.json index 7bd7846d..81be638e 100644 --- a/src/__tests__/CodexACPAgent/data/guardian-approval-review-flow.json +++ b/src/__tests__/CodexACPAgent/data/guardian-approval-review-flow.json @@ -14,22 +14,18 @@ "type": "content", "content": { "type": "text", - "text": "Status: In progress\nAction: exec /bin/ls -l\nRisk: medium\nAuthorization: unknown\nRationale: Checking whether this command should run automatically." + "text": "Action: exec /bin/ls -l" + } + }, + { + "type": "content", + "content": { + "type": "text", + "text": "Status: In progress\nRisk: medium\nAuthorization: unknown\nRationale: Checking whether this command should run automatically." } } ], "rawInput": { - "threadId": "test-session-id", - "turnId": "turn-1", - "startedAtMs": 1000, - "reviewId": "review-1", - "targetItemId": "command-1", - "review": { - "status": "inProgress", - "riskLevel": "medium", - "userAuthorization": "unknown", - "rationale": "Checking whether this command should run automatically." - }, "action": { "type": "execve", "source": "unifiedExec", @@ -59,35 +55,17 @@ "type": "content", "content": { "type": "text", - "text": "Status: Approved\nAction: exec /bin/ls -l\nRisk: low\nAuthorization: medium\nRationale: The command only lists files." + "text": "Action: exec /bin/ls -l" } - } - ], - "rawOutput": { - "threadId": "test-session-id", - "turnId": "turn-1", - "startedAtMs": 1000, - "completedAtMs": 1500, - "reviewId": "review-1", - "targetItemId": "command-1", - "decisionSource": "agent", - "review": { - "status": "approved", - "riskLevel": "low", - "userAuthorization": "medium", - "rationale": "The command only lists files." }, - "action": { - "type": "execve", - "source": "unifiedExec", - "program": "/bin/ls", - "argv": [ - "/bin/ls", - "-l" - ], - "cwd": "/test/project" + { + "type": "content", + "content": { + "type": "text", + "text": "Status: Approved\nRisk: low\nAuthorization: medium\nRationale: The command only lists files." + } } - } + ] } } ] diff --git a/src/__tests__/CodexACPAgent/data/image-generation-completed-only.json b/src/__tests__/CodexACPAgent/data/image-generation-completed-only.json index 73d9bf87..fb286b4c 100644 --- a/src/__tests__/CodexACPAgent/data/image-generation-completed-only.json +++ b/src/__tests__/CodexACPAgent/data/image-generation-completed-only.json @@ -18,12 +18,7 @@ "mimeType": "image/png" } } - ], - "rawOutput": { - "status": "generating", - "revisedPrompt": null, - "result": "iVBORw0KGgo=" - } + ] } } ] diff --git a/src/__tests__/CodexACPAgent/data/image-generation-flow.json b/src/__tests__/CodexACPAgent/data/image-generation-flow.json index 176aefdd..0fb8d779 100644 --- a/src/__tests__/CodexACPAgent/data/image-generation-flow.json +++ b/src/__tests__/CodexACPAgent/data/image-generation-flow.json @@ -8,10 +8,7 @@ "toolCallId": "image-generation-1", "kind": "other", "title": "Image generation", - "status": "in_progress", - "rawInput": { - "id": "image-generation-1" - } + "status": "in_progress" } } ] @@ -42,13 +39,7 @@ "uri": "/tmp/codex/generated-blue-square.png" } } - ], - "rawOutput": { - "status": "generating", - "revisedPrompt": "A tiny blue square", - "result": "iVBORw0KGgo=", - "savedPath": "/tmp/codex/generated-blue-square.png" - } + ] } } ] diff --git a/src/__tests__/CodexACPAgent/data/load-session-history.json b/src/__tests__/CodexACPAgent/data/load-session-history.json index 0c9e36f3..f22c0d67 100644 --- a/src/__tests__/CodexACPAgent/data/load-session-history.json +++ b/src/__tests__/CodexACPAgent/data/load-session-history.json @@ -21,16 +21,7 @@ { "name": "plan", "description": "Turn plan mode on.", - "input": null, - "_meta": { - "commandAction": { - "kind": "setConfigOption", - "configId": "collaboration_mode", - "value": "plan", - "resetValue": "default", - "presentation": "state" - } - } + "input": null }, { "name": "mcp", @@ -78,12 +69,6 @@ "description": "Set a goal to keep pursuing.", "input": { "hint": "[|clear|pause|resume]" - }, - "_meta": { - "commandAction": { - "kind": "prefixPrompt", - "presentation": "state" - } } }, { @@ -103,29 +88,6 @@ } ] } -{ - "method": "sessionUpdate", - "args": [ - { - "sessionId": "session-1", - "update": { - "sessionUpdate": "session_info_update", - "_meta": { - "goal": { - "objective": "Keep the restored migration green", - "status": "paused", - "tokenBudget": null, - "tokensUsed": 42, - "timeUsedSeconds": 46, - "createdAt": 1710000000000, - "updatedAt": 1710000046000, - "controlMethod": "_session/goal" - } - } - } - } - ] -} { "method": "sessionUpdate", "args": [ @@ -257,7 +219,6 @@ "update": { "sessionUpdate": "tool_call_update", "toolCallId": "item-cmd-1", - "status": "completed", "rawOutput": { "formatted_output": "Added.txt\nREADME.md\n", "exit_code": 0 @@ -285,8 +246,8 @@ "update": { "sessionUpdate": "tool_call", "toolCallId": "item-file-1", - "title": "Editing files", "kind": "edit", + "title": "Editing files", "status": "completed", "content": [ { @@ -334,15 +295,24 @@ "update": { "sessionUpdate": "tool_call", "toolCallId": "item-dyn-1", + "name": "list_apps", "kind": "execute", "title": "list_apps", "status": "completed", + "content": [ + { + "type": "content", + "content": { + "type": "text", + "text": "Done" + } + } + ], "rawInput": { "arguments": { "includeDisabled": false } - }, - "name": "list_apps" + } } } ] @@ -355,8 +325,8 @@ "update": { "sessionUpdate": "tool_call", "toolCallId": "item-image-view-1", - "kind": "read", "name": "view_image", + "kind": "read", "title": "View Image /test/project/input.png", "status": "completed", "content": [ @@ -430,12 +400,7 @@ "toolCallId": "item-context-compaction-1", "kind": "think", "title": "Compact conversation", - "status": "completed", - "_meta": { - "contextCompaction": { - "version": 1 - } - } + "status": "completed" } } ] @@ -447,23 +412,14 @@ "sessionId": "session-1", "update": { "sessionUpdate": "tool_call", - "title": "Start subagent test_audit", - "kind": "other", "toolCallId": "item-subagent-1", + "kind": "other", + "title": "Start subagent test_audit", "status": "completed", "rawInput": { "agentThreadId": "thread-child-1", "agentPath": "/root/test_audit", "activityKind": "started" - }, - "_meta": { - "codex": { - "subagent": { - "threadId": "thread-child-1", - "path": "/root/test_audit", - "activity": "started" - } - } } } } diff --git a/src/__tests__/CodexACPAgent/data/load-session-response-item-history-fallback.json b/src/__tests__/CodexACPAgent/data/load-session-response-item-history-fallback.json index c1d21f4a..e0f6e550 100644 --- a/src/__tests__/CodexACPAgent/data/load-session-response-item-history-fallback.json +++ b/src/__tests__/CodexACPAgent/data/load-session-response-item-history-fallback.json @@ -21,16 +21,7 @@ { "name": "plan", "description": "Turn plan mode on.", - "input": null, - "_meta": { - "commandAction": { - "kind": "setConfigOption", - "configId": "collaboration_mode", - "value": "plan", - "resetValue": "default", - "presentation": "state" - } - } + "input": null }, { "name": "mcp", @@ -78,12 +69,6 @@ "description": "Set a goal to keep pursuing.", "input": { "hint": "[|clear|pause|resume]" - }, - "_meta": { - "commandAction": { - "kind": "prefixPrompt", - "presentation": "state" - } } }, { @@ -103,20 +88,6 @@ } ] } -{ - "method": "sessionUpdate", - "args": [ - { - "sessionId": "session-legacy", - "update": { - "sessionUpdate": "session_info_update", - "_meta": { - "goal": null - } - } - } - ] -} { "method": "sessionUpdate", "args": [ @@ -172,11 +143,6 @@ "content": { "type": "text", "text": "Inspect project files" - }, - "_meta": { - "codex": { - "phase": "final_answer" - } } } } @@ -190,10 +156,10 @@ "update": { "sessionUpdate": "tool_call", "toolCallId": "call-rg", - "status": "in_progress", + "name": "exec_command", "kind": "search", "title": "Search for 'Service' in src", - "name": "exec_command" + "status": "in_progress" } } ] @@ -222,10 +188,10 @@ "update": { "sessionUpdate": "tool_call", "toolCallId": "call-rg-failed", - "status": "in_progress", + "name": "exec_command", "kind": "search", "title": "Search for 'Missing' in src", - "name": "exec_command" + "status": "in_progress" } } ] @@ -254,15 +220,15 @@ "update": { "sessionUpdate": "tool_call", "toolCallId": "call-read", - "status": "in_progress", + "name": "exec_command", "kind": "read", "title": "Read file '/test/project/src/index.ts'", + "status": "in_progress", "locations": [ { "path": "/test/project/src/index.ts" } - ], - "name": "exec_command" + ] } } ] @@ -291,10 +257,10 @@ "update": { "sessionUpdate": "tool_call", "toolCallId": "call-ls", - "status": "in_progress", + "name": "exec_command", "kind": "read", "title": "List files", - "name": "exec_command" + "status": "in_progress" } } ] diff --git a/src/__tests__/CodexACPAgent/data/mcp-tool-completed-with-logs.json b/src/__tests__/CodexACPAgent/data/mcp-tool-completed-with-logs.json index 07c4eb6d..a180c7d6 100644 --- a/src/__tests__/CodexACPAgent/data/mcp-tool-completed-with-logs.json +++ b/src/__tests__/CodexACPAgent/data/mcp-tool-completed-with-logs.json @@ -26,23 +26,6 @@ } ] } -{ - "method": "sessionUpdate", - "args": [ - { - "sessionId": "test-session-id", - "update": { - "sessionUpdate": "tool_call_update", - "toolCallId": "call-id", - "_meta": { - "mcp_output_delta": { - "data": "File /Users/aleksandr.slapoguzov/Projects/ultimate/.ai/local.md doesn't exist or can't be opened" - } - } - } - } - ] -} { "method": "sessionUpdate", "args": [ @@ -52,16 +35,6 @@ "sessionUpdate": "tool_call_update", "toolCallId": "call-id", "status": "failed", - "rawInput": { - "server": "ijproxy", - "tool": "read_file", - "arguments": { - "file_path": ".ai/local.md", - "mode": "slice", - "start_line": 1, - "max_lines": 200 - } - }, "rawOutput": { "result": null, "error": { diff --git a/src/__tests__/CodexACPAgent/data/mcp-tool-repeated-progress.json b/src/__tests__/CodexACPAgent/data/mcp-tool-repeated-progress.json index 01fe22d5..4df6129b 100644 --- a/src/__tests__/CodexACPAgent/data/mcp-tool-repeated-progress.json +++ b/src/__tests__/CodexACPAgent/data/mcp-tool-repeated-progress.json @@ -23,40 +23,6 @@ } ] } -{ - "method": "sessionUpdate", - "args": [ - { - "sessionId": "test-session-id", - "update": { - "sessionUpdate": "tool_call_update", - "toolCallId": "call-id", - "_meta": { - "mcp_output_delta": { - "data": "Polling for status" - } - } - } - } - ] -} -{ - "method": "sessionUpdate", - "args": [ - { - "sessionId": "test-session-id", - "update": { - "sessionUpdate": "tool_call_update", - "toolCallId": "call-id", - "_meta": { - "mcp_output_delta": { - "data": "Polling for status" - } - } - } - } - ] -} { "method": "sessionUpdate", "args": [ @@ -66,13 +32,6 @@ "sessionUpdate": "tool_call_update", "toolCallId": "call-id", "status": "failed", - "rawInput": { - "server": "server-name", - "tool": "tool-name", - "arguments": { - "argument": "example" - } - }, "rawOutput": { "result": null, "error": { diff --git a/src/__tests__/CodexACPAgent/data/plan-completed-fallback.json b/src/__tests__/CodexACPAgent/data/plan-completed-fallback.json index 4642c754..05b1d2a3 100644 --- a/src/__tests__/CodexACPAgent/data/plan-completed-fallback.json +++ b/src/__tests__/CodexACPAgent/data/plan-completed-fallback.json @@ -11,8 +11,11 @@ "text": "### Fallback plan\n\n1. Use the completed item." }, "_meta": { - "codex": { - "phase": "final_answer" + "jetbrains": { + "air": { + "version": 1, + "phase": "final_answer" + } } } } diff --git a/src/__tests__/CodexACPAgent/data/plan-delta-fallback.json b/src/__tests__/CodexACPAgent/data/plan-delta-fallback.json index 87a37988..2283ab8e 100644 --- a/src/__tests__/CodexACPAgent/data/plan-delta-fallback.json +++ b/src/__tests__/CodexACPAgent/data/plan-delta-fallback.json @@ -8,11 +8,38 @@ "messageId": "plan-2", "content": { "type": "text", - "text": "### Buffered plan\n\n1. Use the buffered fallback." + "text": "### Buffered plan\n\n" }, "_meta": { - "codex": { - "phase": "final_answer" + "jetbrains": { + "air": { + "version": 1, + "phase": "final_answer" + } + } + } + } + } + ] +} +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "test-session-id", + "update": { + "sessionUpdate": "agent_message_chunk", + "messageId": "plan-2", + "content": { + "type": "text", + "text": "1. Use the buffered fallback." + }, + "_meta": { + "jetbrains": { + "air": { + "version": 1, + "phase": "final_answer" + } } } } diff --git a/src/__tests__/CodexACPAgent/data/plan-deltas.json b/src/__tests__/CodexACPAgent/data/plan-deltas.json index e2036870..1e100507 100644 --- a/src/__tests__/CodexACPAgent/data/plan-deltas.json +++ b/src/__tests__/CodexACPAgent/data/plan-deltas.json @@ -8,11 +8,38 @@ "messageId": "plan-1", "content": { "type": "text", - "text": "Completed text should not duplicate the streamed plan." + "text": "### Implementation plan\n\n" }, "_meta": { - "codex": { - "phase": "final_answer" + "jetbrains": { + "air": { + "version": 1, + "phase": "final_answer" + } + } + } + } + } + ] +} +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "test-session-id", + "update": { + "sessionUpdate": "agent_message_chunk", + "messageId": "plan-1", + "content": { + "type": "text", + "text": "1. Add the event mapping.\n2. Verify it." + }, + "_meta": { + "jetbrains": { + "air": { + "version": 1, + "phase": "final_answer" + } } } } diff --git a/src/__tests__/CodexACPAgent/data/response-item-history-tool-names.json b/src/__tests__/CodexACPAgent/data/response-item-history-tool-names.json index 12266984..5dd5e5c3 100644 --- a/src/__tests__/CodexACPAgent/data/response-item-history-tool-names.json +++ b/src/__tests__/CodexACPAgent/data/response-item-history-tool-names.json @@ -2,10 +2,10 @@ { "sessionUpdate": "tool_call", "toolCallId": "call-search", - "status": "in_progress", + "name": "exec_command", "kind": "search", "title": "Search for 'Needle' in src", - "name": "exec_command" + "status": "in_progress" }, { "sessionUpdate": "tool_call", diff --git a/src/__tests__/CodexACPAgent/data/session-compaction-legacy.json b/src/__tests__/CodexACPAgent/data/session-compaction-legacy.json index 1e208197..4f4c088d 100644 --- a/src/__tests__/CodexACPAgent/data/session-compaction-legacy.json +++ b/src/__tests__/CodexACPAgent/data/session-compaction-legacy.json @@ -10,8 +10,13 @@ "title": "Compact conversation", "status": "in_progress", "_meta": { - "contextCompaction": { - "version": 1 + "jetbrains": { + "air": { + "version": 1, + "contextCompaction": { + "version": 1 + } + } } } } @@ -26,13 +31,7 @@ "update": { "sessionUpdate": "tool_call_update", "toolCallId": "compaction-item", - "title": "Compact conversation", - "status": "completed", - "_meta": { - "contextCompaction": { - "version": 1 - } - } + "status": "completed" } } ] diff --git a/src/__tests__/CodexACPAgent/data/session-notices-replay.json b/src/__tests__/CodexACPAgent/data/session-notices-replay.json index 784b649f..deee5f45 100644 --- a/src/__tests__/CodexACPAgent/data/session-notices-replay.json +++ b/src/__tests__/CodexACPAgent/data/session-notices-replay.json @@ -25,12 +25,7 @@ "toolCallId": "history-compaction", "kind": "think", "title": "Compact conversation", - "status": "completed", - "_meta": { - "contextCompaction": { - "version": 1 - } - } + "status": "completed" } } ] diff --git a/src/__tests__/CodexACPAgent/data/terminal-command-completed.json b/src/__tests__/CodexACPAgent/data/terminal-command-completed.json index 27d6d1cf..0135ad44 100644 --- a/src/__tests__/CodexACPAgent/data/terminal-command-completed.json +++ b/src/__tests__/CodexACPAgent/data/terminal-command-completed.json @@ -11,6 +11,11 @@ "terminal_output_delta": { "data": "file1.txt\nfile2.txt\nfile3.txt\n", "terminal_id": "command-123" + }, + "terminal_exit": { + "exit_code": 0, + "signal": null, + "terminal_id": "command-123" } } } diff --git a/src/__tests__/CodexACPAgent/data/terminal-command-failed.json b/src/__tests__/CodexACPAgent/data/terminal-command-failed.json index 63c25d84..27110b50 100644 --- a/src/__tests__/CodexACPAgent/data/terminal-command-failed.json +++ b/src/__tests__/CodexACPAgent/data/terminal-command-failed.json @@ -7,9 +7,16 @@ "sessionUpdate": "tool_call_update", "toolCallId": "command-456", "status": "failed", - "rawOutput": { - "formatted_output": "cat: nonexistent.txt: No such file or directory", - "exit_code": 1 + "_meta": { + "terminal_output_delta": { + "data": "cat: nonexistent.txt: No such file or directory", + "terminal_id": "command-456" + }, + "terminal_exit": { + "exit_code": 1, + "signal": null, + "terminal_id": "command-456" + } } } } diff --git a/src/__tests__/CodexACPAgent/data/terminal-interaction-stdin.json b/src/__tests__/CodexACPAgent/data/terminal-interaction-stdin.json index e8e1dcf7..ca9cea32 100644 --- a/src/__tests__/CodexACPAgent/data/terminal-interaction-stdin.json +++ b/src/__tests__/CodexACPAgent/data/terminal-interaction-stdin.json @@ -7,8 +7,8 @@ "sessionUpdate": "tool_call_update", "toolCallId": "command-123", "_meta": { - "terminal_output_delta": { - "data": "\ncontinue\n", + "terminal_input": { + "data": "continue", "terminal_id": "command-123" } } diff --git a/src/__tests__/CodexACPAgent/data/terminal-output-parsed-command-legacy-delta.json b/src/__tests__/CodexACPAgent/data/terminal-output-parsed-command-legacy-delta.json index 7f497277..58c71869 100644 --- a/src/__tests__/CodexACPAgent/data/terminal-output-parsed-command-legacy-delta.json +++ b/src/__tests__/CodexACPAgent/data/terminal-output-parsed-command-legacy-delta.json @@ -6,9 +6,9 @@ "update": { "sessionUpdate": "tool_call", "toolCallId": "command-read-file", - "status": "in_progress", "kind": "read", "title": "Read file '/test/project/README.md'", + "status": "in_progress", "locations": [ { "path": "/test/project/README.md" @@ -18,24 +18,6 @@ } ] } -{ - "method": "sessionUpdate", - "args": [ - { - "sessionId": "test-session-id", - "update": { - "sessionUpdate": "tool_call_update", - "toolCallId": "command-read-file", - "_meta": { - "terminal_output_delta": { - "data": "# Project\n", - "terminal_id": "command-read-file" - } - } - } - } - ] -} { "method": "sessionUpdate", "args": [ @@ -45,10 +27,15 @@ "sessionUpdate": "tool_call_update", "toolCallId": "command-read-file", "status": "completed", - "rawOutput": { - "formatted_output": "# Project\n", - "exit_code": 0 - } + "content": [ + { + "type": "content", + "content": { + "type": "text", + "text": "# Project\n" + } + } + ] } } ] diff --git a/src/__tests__/CodexACPAgent/data/thread-goal-cleared.json b/src/__tests__/CodexACPAgent/data/thread-goal-cleared.json index c3a934a1..60369ccb 100644 --- a/src/__tests__/CodexACPAgent/data/thread-goal-cleared.json +++ b/src/__tests__/CodexACPAgent/data/thread-goal-cleared.json @@ -6,7 +6,12 @@ "update": { "sessionUpdate": "session_info_update", "_meta": { - "goal": null + "jetbrains": { + "air": { + "version": 1, + "goal": null + } + } } } } diff --git a/src/__tests__/CodexACPAgent/data/thread-goal-updated-multiline.json b/src/__tests__/CodexACPAgent/data/thread-goal-updated-multiline.json index be3531dc..3d903e77 100644 --- a/src/__tests__/CodexACPAgent/data/thread-goal-updated-multiline.json +++ b/src/__tests__/CodexACPAgent/data/thread-goal-updated-multiline.json @@ -6,15 +6,20 @@ "update": { "sessionUpdate": "session_info_update", "_meta": { - "goal": { - "objective": "First task\nSecond task", - "status": "limited", - "tokenBudget": 1000, - "tokensUsed": 1000, - "timeUsedSeconds": 30, - "createdAt": 1710000000000, - "updatedAt": 1710000030000, - "controlMethod": "_session/goal" + "jetbrains": { + "air": { + "version": 1, + "goal": { + "objective": "First task\nSecond task", + "status": "limited", + "tokenBudget": 1000, + "tokensUsed": 1000, + "timeUsedSeconds": 30, + "createdAt": 1710000000000, + "updatedAt": 1710000030000, + "controlMethod": "_session/goal" + } + } } } } diff --git a/src/__tests__/CodexACPAgent/data/thread-goal-updated.json b/src/__tests__/CodexACPAgent/data/thread-goal-updated.json index 2682e5e6..2f29a5b6 100644 --- a/src/__tests__/CodexACPAgent/data/thread-goal-updated.json +++ b/src/__tests__/CodexACPAgent/data/thread-goal-updated.json @@ -6,15 +6,20 @@ "update": { "sessionUpdate": "session_info_update", "_meta": { - "goal": { - "objective": "Ship the goal update", - "status": "active", - "tokenBudget": null, - "tokensUsed": 42, - "timeUsedSeconds": 12, - "createdAt": 1710000000000, - "updatedAt": 1710000012000, - "controlMethod": "_session/goal" + "jetbrains": { + "air": { + "version": 1, + "goal": { + "objective": "Ship the goal update", + "status": "active", + "tokenBudget": null, + "tokensUsed": 42, + "timeUsedSeconds": 12, + "createdAt": 1710000000000, + "updatedAt": 1710000012000, + "controlMethod": "_session/goal" + } + } } } } diff --git a/src/__tests__/CodexACPAgent/data/tool-call-command-names.json b/src/__tests__/CodexACPAgent/data/tool-call-command-names.json index 156ab43b..35376bd1 100644 --- a/src/__tests__/CodexACPAgent/data/tool-call-command-names.json +++ b/src/__tests__/CodexACPAgent/data/tool-call-command-names.json @@ -38,15 +38,15 @@ "update": { "sessionUpdate": "tool_call", "toolCallId": "unifiedExecStartup-read", - "status": "in_progress", + "name": "exec_command", "kind": "read", "title": "Read file '/repo/config.json'", + "status": "in_progress", "locations": [ { "path": "/repo/config.json" } - ], - "name": "exec_command" + ] } } ] @@ -91,15 +91,15 @@ "update": { "sessionUpdate": "tool_call", "toolCallId": "unifiedExecInteraction-read", - "status": "in_progress", + "name": "write_stdin", "kind": "read", "title": "Read file '/repo/config.json'", + "status": "in_progress", "locations": [ { "path": "/repo/config.json" } - ], - "name": "write_stdin" + ] } } ] @@ -143,9 +143,9 @@ "update": { "sessionUpdate": "tool_call", "toolCallId": "agent-read", - "status": "in_progress", "kind": "read", "title": "Read file '/repo/config.json'", + "status": "in_progress", "locations": [ { "path": "/repo/config.json" @@ -194,9 +194,9 @@ "update": { "sessionUpdate": "tool_call", "toolCallId": "userShell-read", - "status": "in_progress", "kind": "read", "title": "Read file '/repo/config.json'", + "status": "in_progress", "locations": [ { "path": "/repo/config.json" diff --git a/src/__tests__/CodexACPAgent/data/tool-call-completed-name.json b/src/__tests__/CodexACPAgent/data/tool-call-completed-name.json index 2441f4c4..5fb74f31 100644 --- a/src/__tests__/CodexACPAgent/data/tool-call-completed-name.json +++ b/src/__tests__/CodexACPAgent/data/tool-call-completed-name.json @@ -8,9 +8,12 @@ "toolCallId": "unifiedExecStartup-execute", "name": "exec_command", "status": "completed", - "rawOutput": { - "formatted_output": "", - "exit_code": null + "_meta": { + "terminal_exit": { + "exit_code": null, + "signal": null, + "terminal_id": "unifiedExecStartup-execute" + } } } } diff --git a/src/__tests__/CodexACPAgent/data/tool-call-dynamic-names.json b/src/__tests__/CodexACPAgent/data/tool-call-dynamic-names.json index 06e22ad7..65d0d1ba 100644 --- a/src/__tests__/CodexACPAgent/data/tool-call-dynamic-names.json +++ b/src/__tests__/CodexACPAgent/data/tool-call-dynamic-names.json @@ -6,6 +6,7 @@ "update": { "sessionUpdate": "tool_call", "toolCallId": "plain-tool", + "name": "read_file", "kind": "execute", "title": "read_file", "status": "in_progress", @@ -13,8 +14,7 @@ "arguments": { "path": "/repo/config.json" } - }, - "name": "read_file" + } } } ] @@ -27,7 +27,6 @@ "update": { "sessionUpdate": "tool_call_update", "toolCallId": "plain-tool", - "name": "read_file", "status": "completed" } } @@ -41,6 +40,7 @@ "update": { "sessionUpdate": "tool_call", "toolCallId": "functions.tool", + "name": "functions.read_file", "kind": "execute", "title": "read_file", "status": "in_progress", @@ -48,8 +48,7 @@ "arguments": { "path": "/repo/config.json" } - }, - "name": "functions.read_file" + } } } ] @@ -62,7 +61,6 @@ "update": { "sessionUpdate": "tool_call_update", "toolCallId": "functions.tool", - "name": "functions.read_file", "status": "completed" } } @@ -76,6 +74,7 @@ "update": { "sessionUpdate": "tool_call", "toolCallId": "toolstool", + "name": "toolsread_file", "kind": "execute", "title": "read_file", "status": "in_progress", @@ -83,8 +82,7 @@ "arguments": { "path": "/repo/config.json" } - }, - "name": "toolsread_file" + } } } ] @@ -97,7 +95,6 @@ "update": { "sessionUpdate": "tool_call_update", "toolCallId": "toolstool", - "name": "toolsread_file", "status": "completed" } } diff --git a/src/__tests__/CodexACPAgent/data/view-image-flow.json b/src/__tests__/CodexACPAgent/data/view-image-flow.json index 8fd5c5d1..a261fe8b 100644 --- a/src/__tests__/CodexACPAgent/data/view-image-flow.json +++ b/src/__tests__/CodexACPAgent/data/view-image-flow.json @@ -6,8 +6,8 @@ "update": { "sessionUpdate": "tool_call", "toolCallId": "view-image-1", - "kind": "read", "name": "view_image", + "kind": "read", "title": "View Image /tmp/codex/input.png", "status": "completed", "content": [ diff --git a/src/__tests__/CodexACPAgent/data/web-search-action-titles.json b/src/__tests__/CodexACPAgent/data/web-search-action-titles.json index 09bc7e92..e22175bb 100644 --- a/src/__tests__/CodexACPAgent/data/web-search-action-titles.json +++ b/src/__tests__/CodexACPAgent/data/web-search-action-titles.json @@ -10,8 +10,6 @@ "title": "Open page: https://agentclientprotocol.com", "status": "in_progress", "rawInput": { - "type": "webSearch", - "id": "web-open-1", "query": "https://agentclientprotocol.com", "action": { "type": "openPage", @@ -34,8 +32,6 @@ "title": "Find in page for 'tool calls' in https://agentclientprotocol.com/protocol", "status": "in_progress", "rawInput": { - "type": "webSearch", - "id": "web-find-1", "query": "protocol", "action": { "type": "findInPage", diff --git a/src/__tests__/CodexACPAgent/data/web-search-start-and-complete.json b/src/__tests__/CodexACPAgent/data/web-search-start-and-complete.json index 4b93beba..e050c1b3 100644 --- a/src/__tests__/CodexACPAgent/data/web-search-start-and-complete.json +++ b/src/__tests__/CodexACPAgent/data/web-search-start-and-complete.json @@ -10,8 +10,6 @@ "title": "Web search: agent client protocol", "status": "in_progress", "rawInput": { - "type": "webSearch", - "id": "web-search-1", "query": "agent client protocol", "action": { "type": "search", @@ -31,18 +29,7 @@ "update": { "sessionUpdate": "tool_call_update", "toolCallId": "web-search-1", - "title": "Web search: agent client protocol", - "status": "completed", - "rawInput": { - "type": "webSearch", - "id": "web-search-1", - "query": "agent client protocol", - "action": { - "type": "search", - "query": "agent client protocol", - "queries": null - } - } + "status": "completed" } } ] diff --git a/src/__tests__/CodexACPAgent/elicitation-events.test.ts b/src/__tests__/CodexACPAgent/elicitation-events.test.ts index 32ca1171..7ba6637f 100644 --- a/src/__tests__/CodexACPAgent/elicitation-events.test.ts +++ b/src/__tests__/CodexACPAgent/elicitation-events.test.ts @@ -335,7 +335,7 @@ describe('Elicitation Events', () => { method: 'requestPermission', args: [{ sessionId, - toolCall: {toolCallId: 'call-id', kind: 'execute', status: 'pending'}, + toolCall: {toolCallId: 'call-id', status: 'pending'}, }], }); expect(events[0]!.args[0].options.map((option: {name: string}) => option.name)).toEqual([ diff --git a/src/__tests__/CodexACPAgent/file-change-events.test.ts b/src/__tests__/CodexACPAgent/file-change-events.test.ts index a24e0279..1dcd33ef 100644 --- a/src/__tests__/CodexACPAgent/file-change-events.test.ts +++ b/src/__tests__/CodexACPAgent/file-change-events.test.ts @@ -1,11 +1,18 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { SessionState } from '../../CodexAcpServer'; import type { ServerNotification } from '../../app-server'; -import { createFileChangeUpdate } from '../../CodexToolCallMapper'; +import { AcpToolCallRenderer } from '../../tool-calls/AcpToolCallRenderer'; +import { ClientCapabilities } from '../../tool-calls/ClientCapabilities'; +import { FileChangeReporter } from '../../tool-calls/reporters/FileChangeReporter'; + import type { ThreadItem } from '../../app-server/v2'; import { createCodexMockTestFixture, createTestSessionState, setupPromptAndSendNotifications, type CodexMockTestFixture } from '../acp-test-utils'; import {AgentMode} from "../../AgentMode"; +async function createFileChangeUpdate(item: ThreadItem & {type: 'fileChange'}, diffPatch = false) { + return new AcpToolCallRenderer(ClientCapabilities.DEFAULT).render(await FileChangeReporter.started(item, diffPatch)); +} + const { mockFiles, mockReadDelays, mockFileContent, delayMockFileRead, removeMockFile, clearMockFiles } = vi.hoisted(() => { const files = new Map(); const readDelays = new Map>(); diff --git a/src/__tests__/CodexACPAgent/fuzzy-file-search-events.test.ts b/src/__tests__/CodexACPAgent/fuzzy-file-search-events.test.ts index c3504ce1..b17f0936 100644 --- a/src/__tests__/CodexACPAgent/fuzzy-file-search-events.test.ts +++ b/src/__tests__/CodexACPAgent/fuzzy-file-search-events.test.ts @@ -105,8 +105,6 @@ describe("CodexEventHandler - fuzzy file search events", () => { update: { sessionUpdate: "tool_call_update", toolCallId: "fuzzyFileSearch.search-1", - title: "Search for 'event handler'", - status: "in_progress", locations: [{ path: "/repo/src/CodexEventHandler.ts" }], }, }, diff --git a/src/__tests__/CodexACPAgent/initialize.test.ts b/src/__tests__/CodexACPAgent/initialize.test.ts index 537046b0..45e2bd67 100644 --- a/src/__tests__/CodexACPAgent/initialize.test.ts +++ b/src/__tests__/CodexACPAgent/initialize.test.ts @@ -70,16 +70,28 @@ describe('CodexACPAgent - initialize', () => { steering: { supported: true, }, - goal: { + }, + }); + }); + + it('should advertise the AIR extension only to AIR', async () => { + const result = await agent.initialize({ + protocolVersion: acp.PROTOCOL_VERSION, + clientCapabilities: {_meta: {jetbrains: {air: {version: 1, capabilities: []}}}}, + }); + expect(result._meta).toEqual({ + steering: { + supported: true, + }, + jetbrains: { + air: { version: 1, - controlMethod: "_session/goal", - actions: ["set", "pause", "resume", "clear"], - }, - jetbrains: { - air: { + goal: { version: 1, - capabilities: ["sessionFailure", "diffPatch", "agentFileChangeReport", "nativeSubagentSessions", "asyncTasks", "recommendedValue"], + controlMethod: "_session/goal", + actions: ["set", "pause", "resume", "clear"], }, + capabilities: ["sessionFailure", "diffPatch", "agentFileChangeReport", "nativeSubagentSessions", "asyncTasks", "recommendedValue", "rawInputRendering", "planContentDelta"], }, }, }); diff --git a/src/__tests__/CodexACPAgent/load-session.test.ts b/src/__tests__/CodexACPAgent/load-session.test.ts index 6b3cca00..7f03808e 100644 --- a/src/__tests__/CodexACPAgent/load-session.test.ts +++ b/src/__tests__/CodexACPAgent/load-session.test.ts @@ -896,7 +896,7 @@ describe("CodexACPAgent - loadSession", () => { await vi.waitFor(() => { const dump = fixture.getAcpConnectionDump([]); - expect(dump).toContain('"toolCallId": "mcp_startup.broken-mcp"'); + expect(dump).toMatch(/"toolCallId": "mcp_startup\.broken-mcp\.[0-9a-f-]{36}"/); expect(dump).toContain('MCP server `broken-mcp` failed to start: boom'); }); }); diff --git a/src/__tests__/CodexACPAgent/plan-events.test.ts b/src/__tests__/CodexACPAgent/plan-events.test.ts index aa22e89a..6684f906 100644 --- a/src/__tests__/CodexACPAgent/plan-events.test.ts +++ b/src/__tests__/CodexACPAgent/plan-events.test.ts @@ -4,6 +4,7 @@ import {AgentMode} from "../../AgentMode"; import type {SessionState} from "../../CodexAcpServer"; import {CodexEventHandler} from "../../CodexEventHandler"; import type {AcpClientConnection} from "../../ACPSessionConnection"; +import type {ClientCapabilities} from "../../tool-calls/ClientCapabilities"; import { createCodexMockTestFixture, createTestSessionState, @@ -30,7 +31,7 @@ describe("CodexEventHandler - plan events", () => { agentMode: AgentMode.DEFAULT_AGENT_MODE, }); - it("emits the authoritative completed plan after buffering deltas", async () => { + it("streams plan deltas as message text and keeps them when the completed plan differs", async () => { const notifications: ServerNotification[] = [ { method: "item/started", @@ -85,7 +86,7 @@ describe("CodexEventHandler - plan events", () => { ); }); - it("falls back to buffered deltas when the completed plan is empty", async () => { + it("sends nothing more when the completed plan is empty after streamed deltas", async () => { const notifications: ServerNotification[] = [ { method: "item/plan/delta", @@ -183,12 +184,16 @@ describe("CodexEventHandler - plan events", () => { describe("plan update coalescing", () => { function createHandler( notify = vi.fn(async (_method: unknown, _params: unknown) => {}), + capabilities: Parameters[0] = {}, ) { const connection = { notify, request: vi.fn(), } as unknown as AcpClientConnection; - const handler = new CodexEventHandler(connection, sessionState, true); + const handler = new CodexEventHandler(connection, { + ...sessionState, + clientCapabilities: sessionState.clientCapabilities.with({planUpdates: true, ...capabilities}), + }); const planUpdates = () => notify.mock.calls .map(call => call[1] as {update?: {sessionUpdate?: string, plan?: {planId: string, content: string}}}) .filter(params => params.update?.sessionUpdate === "plan_update") diff --git a/src/__tests__/CodexACPAgent/plan-review-events.test.ts b/src/__tests__/CodexACPAgent/plan-review-events.test.ts index 67dbfc73..5f003744 100644 --- a/src/__tests__/CodexACPAgent/plan-review-events.test.ts +++ b/src/__tests__/CodexACPAgent/plan-review-events.test.ts @@ -1,6 +1,7 @@ import * as acp from "@agentclientprotocol/sdk"; import {beforeEach, describe, expect, it, vi} from "vitest"; import {PLAN_COLLABORATION_MODE} from "../../CollaborationModeConfig"; +import {ClientCapabilities} from "../../tool-calls/ClientCapabilities"; import { createCodexMockTestFixture, createTestSessionState, @@ -65,14 +66,14 @@ describe("CodexACPAgent - plan review", () => { permissionResponse?: acp.RequestPermissionResponse | Promise; } = {}, ) { + // The plan review of these tests is the AIR shape. + const clientCapabilities: acp.ClientCapabilities = { + plan: {}, + _meta: {jetbrains: {air: {version: 1, capabilities: options.typedFailures ? ["sessionFailure"] : []}}}, + }; await fixture.getCodexAcpAgent().initialize({ protocolVersion: acp.PROTOCOL_VERSION, - clientCapabilities: { - plan: {}, - ...(options.typedFailures - ? {_meta: {jetbrains: {air: {version: 1, capabilities: ["sessionFailure"]}}}} - : {}), - }, + clientCapabilities, }); fixture.setPermissionResponse(options.permissionResponse ?? (permissionOptionId === null ? {outcome: {outcome: "cancelled"}} @@ -81,6 +82,7 @@ describe("CodexACPAgent - plan review", () => { const sessionState = createTestSessionState({ sessionId, collaborationMode: PLAN_COLLABORATION_MODE, + clientCapabilities: ClientCapabilities.from(clientCapabilities), }); vi.spyOn(fixture.getCodexAcpAgent(), "getSessionState").mockReturnValue(sessionState); @@ -183,7 +185,6 @@ describe("CodexACPAgent - plan review", () => { toolCallId: "plan-review:plan-item", title: "Implement this plan?", kind: "switch_mode", - rawInput: {plan: "# Implementation plan\n\n1. Make the change."}, }), options: [ {optionId: "implement_plan", name: "Yes, implement this plan", kind: "allow_once"}, @@ -191,6 +192,10 @@ describe("CodexACPAgent - plan review", () => { ], })], }); + // AIR reads the plan of the review from rawInput.plan. + const request = events.find(event => event.method === "requestPermission")!; + expect(request.args[0].toolCall.rawInput).toEqual({plan: "# Implementation plan\n\n1. Make the change."}); + expect(request.args[0]).not.toHaveProperty("_meta"); expect(events).toContainEqual({ method: "sessionUpdate", args: [{ diff --git a/src/__tests__/CodexACPAgent/response-item-history-fallback.test.ts b/src/__tests__/CodexACPAgent/response-item-history-fallback.test.ts index 780225c0..9f39f54f 100644 --- a/src/__tests__/CodexACPAgent/response-item-history-fallback.test.ts +++ b/src/__tests__/CodexACPAgent/response-item-history-fallback.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import type { UpdateSessionEvent } from "../../ACPSessionConnection"; import { parseResponseItemHistoryFallback } from "../../ResponseItemHistoryFallback"; +import { ClientCapabilities } from "../../tool-calls/ClientCapabilities"; type ToolCallUpdate = Extract; @@ -38,7 +39,7 @@ describe("ResponseItemHistoryFallback", () => { }, }, functionCallOutput("call-search", "Chunk ID: search\nProcess exited with code 0\nOutput:\nsrc/index.ts\n"), - ]), "terminal_output"); + ]), ClientCapabilities.DEFAULT); await expect(`${JSON.stringify(updates, null, 2)}\n`).toMatchFileSnapshot( "data/response-item-history-tool-names.json", @@ -51,7 +52,7 @@ describe("ResponseItemHistoryFallback", () => { functionCallOutput("call-existing", "Chunk ID: existing\nProcess exited with code 0\nOutput:\nsrc/existing.ts\n"), functionCall("call-missing", "rg \"Missing\" src"), functionCallOutput("call-missing", "Chunk ID: missing\nProcess exited with code 0\nOutput:\nsrc/missing.ts\n"), - ]), "terminal_output", new Set(["call-existing"])); + ]), ClientCapabilities.DEFAULT, new Set(["call-existing"])); expect(toolCallIds(updates)).toEqual(["call-missing"]); expect(toolCallUpdateStatuses(updates)).toEqual([ @@ -65,7 +66,7 @@ describe("ResponseItemHistoryFallback", () => { functionCallOutput("call-existing-a", "Chunk ID: existing-a\nProcess exited with code 0\nOutput:\nsrc/a.ts\n"), functionCall("call-existing-b", "rg \"ExistingB\" src"), functionCallOutput("call-existing-b", "Chunk ID: existing-b\nProcess exited with code 0\nOutput:\nsrc/b.ts\n"), - ]), "terminal_output", new Set(["call-existing-a", "call-existing-b"])); + ]), ClientCapabilities.DEFAULT, new Set(["call-existing-a", "call-existing-b"])); expect(toolCallIds(updates)).toEqual([]); expect(toolCallUpdateStatuses(updates)).toEqual([]); @@ -90,13 +91,13 @@ describe("ResponseItemHistoryFallback", () => { }, functionCall("call-search", "rg \"Needle\" src"), functionCallOutput("call-search", "Chunk ID: search\nProcess exited with code 0\nOutput:\nsrc/index.ts\n"), - ]), "terminal_output"); + ]), ClientCapabilities.DEFAULT); expect(thoughtTexts(updates)).toEqual(["Need to inspect the directory."]); }); it("preserves assistant message phase metadata from response items", () => { - const updates = parseResponseItemHistoryFallback(jsonl([ + const history = jsonl([ { type: "response_item", payload: { @@ -108,18 +109,21 @@ describe("ResponseItemHistoryFallback", () => { }, functionCall("call-missing", "ls"), functionCallOutput("call-missing", "Chunk ID: missing\nProcess exited with code 0\nOutput:\nREADME.md\n"), - ]), "terminal_output"); + ]); + const airUpdates = parseResponseItemHistoryFallback(history, ClientCapabilities.DEFAULT.with({ airClient: true })); + const otherUpdates = parseResponseItemHistoryFallback(history, ClientCapabilities.DEFAULT); - expect(agentMessageMetas(updates)).toEqual([ - { codex: { phase: "final_answer" } }, + expect(agentMessageMetas(airUpdates)).toEqual([ + { jetbrains: { air: { version: 1, phase: "final_answer" } } }, ]); + expect(agentMessageMetas(otherUpdates)).toEqual([undefined]); }); it("marks exec command outputs without exit footers failed when they report command errors", () => { const updates = parseResponseItemHistoryFallback(jsonl([ functionCall("call-read-failed", "cat missing.txt"), functionCallOutput("call-read-failed", "Error: No such file or directory\n"), - ]), "terminal_output"); + ]), ClientCapabilities.DEFAULT); expect(toolCallUpdateStatuses(updates)).toEqual([ { toolCallId: "call-read-failed", status: "failed" }, @@ -130,7 +134,7 @@ describe("ResponseItemHistoryFallback", () => { const updates = parseResponseItemHistoryFallback(jsonl([ functionCall("call-read-ok", "cat existing.txt"), functionCallOutput("call-read-ok", "existing file contents\n"), - ]), "terminal_output"); + ]), ClientCapabilities.DEFAULT); expect(toolCallUpdateStatuses(updates)).toEqual([ { toolCallId: "call-read-ok", status: "completed" }, diff --git a/src/__tests__/CodexACPAgent/session-compaction.test.ts b/src/__tests__/CodexACPAgent/session-compaction.test.ts index 30c01a9f..5be90871 100644 --- a/src/__tests__/CodexACPAgent/session-compaction.test.ts +++ b/src/__tests__/CodexACPAgent/session-compaction.test.ts @@ -446,7 +446,13 @@ async function createFixture(clientCapabilities: acp.ClientCapabilities = compac ); } vi.spyOn(agent, "getSessionState").mockReturnValue(sessionState); - await agent.initialize({protocolVersion: 1, clientCapabilities}); + // The test session state renders for AIR, so the client declares AIR too. + await agent.initialize({ + protocolVersion: 1, + clientCapabilities: clientCapabilities._meta == null + ? {...clientCapabilities, _meta: {jetbrains: {air: {version: 1, capabilities: []}}}} + : clientCapabilities, + }); await agent.prompt({sessionId, prompt: [{type: "text", text: "Continue."}]}); fixture.clearAcpConnectionDump(); return fixture; diff --git a/src/__tests__/CodexACPAgent/terminal-output-events.test.ts b/src/__tests__/CodexACPAgent/terminal-output-events.test.ts index 594aa4ae..e18020e9 100644 --- a/src/__tests__/CodexACPAgent/terminal-output-events.test.ts +++ b/src/__tests__/CodexACPAgent/terminal-output-events.test.ts @@ -3,6 +3,7 @@ import type { SessionState } from '../../CodexAcpServer'; import type { ServerNotification } from '../../app-server'; import { createCodexMockTestFixture, createTestSessionState, setupPromptAndSendNotifications, type CodexMockTestFixture } from '../acp-test-utils'; import { AgentMode } from "../../AgentMode"; +import { ClientCapabilities } from '../../tool-calls/ClientCapabilities'; describe('CodexEventHandler - terminal output events', () => { let mockFixture: CodexMockTestFixture; @@ -110,7 +111,7 @@ describe('CodexEventHandler - terminal output events', () => { ); }); - it('should stream terminal interaction stdin as terminal output delta', async () => { + it('should send terminal interaction stdin as terminal input, not as output', async () => { const terminalInteractionNotification: ServerNotification = { method: 'item/commandExecution/terminalInteraction', params: { @@ -132,7 +133,7 @@ describe('CodexEventHandler - terminal output events', () => { it('should send one delta when command completion has no streamed output', async () => { const deltaSessionState = createTestSessionState({ sessionId, - terminalOutputDeltaSupported: true, + clientCapabilities: ClientCapabilities.DEFAULT.with({airClient: true, terminalOutputDelta: true}), }); const commandCompletedNotification: ServerNotification = { method: 'item/completed', @@ -228,7 +229,7 @@ describe('CodexEventHandler - terminal output events', () => { it('should handle full terminal output flow: start -> delta -> complete', async () => { const deltaSessionState = createTestSessionState({ sessionId, - terminalOutputDeltaSupported: true, + clientCapabilities: ClientCapabilities.DEFAULT.with({terminalOutputDelta: true}), }); const commandStartNotification: ServerNotification = { method: 'item/started', @@ -304,7 +305,7 @@ describe('CodexEventHandler - terminal output events', () => { sessionId, currentModelId: 'model-id[effort]', agentMode: AgentMode.DEFAULT_AGENT_MODE, - terminalOutputMode: 'terminal_output', + clientCapabilities: ClientCapabilities.DEFAULT.with({terminalOutput: true}), }); const commandStartNotification: ServerNotification = { method: 'item/started', @@ -389,7 +390,7 @@ describe('CodexEventHandler - terminal output events', () => { sessionId, currentModelId: 'model-id[effort]', agentMode: AgentMode.DEFAULT_AGENT_MODE, - terminalOutputMode: 'terminal_output', + clientCapabilities: ClientCapabilities.DEFAULT.with({terminalOutput: true}), }); const commandStartNotification: ServerNotification = { method: 'item/started', @@ -448,12 +449,12 @@ describe('CodexEventHandler - terminal output events', () => { ); }); - it('should keep parsed non-terminal command output on legacy delta metadata', async () => { + it('should send parsed non-terminal command output once in the content', async () => { const terminalOutputSessionState = createTestSessionState({ sessionId, currentModelId: 'model-id[effort]', agentMode: AgentMode.DEFAULT_AGENT_MODE, - terminalOutputMode: 'terminal_output', + clientCapabilities: ClientCapabilities.DEFAULT.with({airClient: true}), }); const commandStartNotification: ServerNotification = { method: 'item/started', diff --git a/src/__tests__/CodexACPAgent/thread-goal-events.test.ts b/src/__tests__/CodexACPAgent/thread-goal-events.test.ts index db22aacf..07220ed8 100644 --- a/src/__tests__/CodexACPAgent/thread-goal-events.test.ts +++ b/src/__tests__/CodexACPAgent/thread-goal-events.test.ts @@ -125,7 +125,7 @@ describe("CodexEventHandler - thread goal events", () => { expect(events).toHaveLength(1); expect(events[0]!.args[0].update).toEqual({ sessionUpdate: "session_info_update", - _meta: { + _meta: {jetbrains: {air: {version: 1, goal: { objective: "Ship the goal update", status: "active", @@ -136,7 +136,7 @@ describe("CodexEventHandler - thread goal events", () => { updatedAt: 1710000012000, controlMethod: "_session/goal", }, - }, + }}}, }); }); @@ -174,7 +174,7 @@ describe("CodexEventHandler - thread goal events", () => { const events = mockFixture.getAcpConnectionEvents([]); expect(events).toHaveLength(2); - expect(events.map(event => event.args[0].update._meta?.goal?.createdAt)).toEqual([ + expect(events.map(event => event.args[0].update._meta?.jetbrains?.air?.goal?.createdAt)).toEqual([ 1710000000000, 1710000100000, ]); @@ -224,7 +224,7 @@ describe("CodexEventHandler - thread goal events", () => { }); expect(events[1]!.args[0].update).toEqual({ sessionUpdate: "session_info_update", - _meta: { + _meta: {jetbrains: {air: {version: 1, goal: { objective: "tell me a joke", status: "complete", @@ -235,7 +235,7 @@ describe("CodexEventHandler - thread goal events", () => { updatedAt: 1710000012000, controlMethod: "_session/goal", }, - }, + }}}, }); }); @@ -256,9 +256,9 @@ describe("CodexEventHandler - thread goal events", () => { expect(events).toHaveLength(1); expect(events[0]!.args[0].update).toEqual({ sessionUpdate: "session_info_update", - _meta: { + _meta: {jetbrains: {air: {version: 1, goal: null, - }, + }}}, }); }); diff --git a/src/__tests__/CodexACPAgent/turn-diff-events.test.ts b/src/__tests__/CodexACPAgent/turn-diff-events.test.ts index 47280063..fe3a50bc 100644 --- a/src/__tests__/CodexACPAgent/turn-diff-events.test.ts +++ b/src/__tests__/CodexACPAgent/turn-diff-events.test.ts @@ -21,7 +21,6 @@ describe("CodexEventHandler - turn diff events", () => { const handler = new CodexEventHandler( connection, sessionState, - false, true, "test-epoch", undefined, diff --git a/src/__tests__/CodexPlanStream.test.ts b/src/__tests__/CodexPlanStream.test.ts new file mode 100644 index 00000000..abf89918 --- /dev/null +++ b/src/__tests__/CodexPlanStream.test.ts @@ -0,0 +1,96 @@ +import {afterEach, describe, expect, it, vi} from "vitest"; +import {ACPSessionConnection, type AcpClientConnection} from "../ACPSessionConnection"; +import {CodexPlanStream} from "../CodexPlanStream"; +import {ClientCapabilities} from "../tool-calls/ClientCapabilities"; + +function createStream(capabilities: ClientCapabilities) { + const notify = vi.fn(async (_method: unknown, _params: unknown) => {}); + const session = new ACPSessionConnection({notify, request: vi.fn()} as unknown as AcpClientConnection, "s"); + const updates = () => notify.mock.calls.map(call => (call[1] as {update: unknown}).update); + return {stream: new CodexPlanStream(session, capabilities), updates}; +} + +describe("CodexPlanStream", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("sends the whole plan once and then only AIR content deltas", async () => { + vi.useFakeTimers(); + const {stream, updates} = createStream(ClientCapabilities.DEFAULT.with({ + planUpdates: true, + air: {planContentDelta: true}, + })); + + stream.delta("plan", "# Plan\n"); + await stream.flush(); + stream.delta("plan", "1. Step"); + await stream.flush(); + await stream.completed("plan", "# Plan\n1. Step"); + + expect(updates()).toEqual([ + {sessionUpdate: "plan_update", plan: {type: "markdown", planId: "plan", content: "# Plan\n"}}, + { + sessionUpdate: "plan_update", + plan: {type: "markdown", planId: "plan", content: ""}, + _meta: {jetbrains: {air: {version: 1, contentDelta: "1. Step"}}}, + }, + ]); + }); + + it("replaces the plan with a snapshot when the completed plan differs from the streamed text", async () => { + const {stream, updates} = createStream(ClientCapabilities.DEFAULT.with({ + planUpdates: true, + air: {planContentDelta: true}, + })); + + stream.delta("plan", "draft"); + await stream.flush(); + await stream.completed("plan", "final"); + + expect(updates().at(-1)).toEqual({ + sessionUpdate: "plan_update", + plan: {type: "markdown", planId: "plan", content: "final"}, + }); + }); + + it("sends snapshots to a client without the AIR plan content delta", async () => { + const {stream, updates} = createStream(ClientCapabilities.DEFAULT.with({planUpdates: true})); + + stream.delta("plan", "# Plan\n"); + await stream.flush(); + stream.delta("plan", "1. Step"); + await stream.flush(); + + expect(updates()).toEqual([ + {sessionUpdate: "plan_update", plan: {type: "markdown", planId: "plan", content: "# Plan\n"}}, + {sessionUpdate: "plan_update", plan: {type: "markdown", planId: "plan", content: "# Plan\n1. Step"}}, + ]); + }); + + it("streams message text to AIR without plan updates and sends only the missing end", async () => { + const {stream} = createStream(ClientCapabilities.DEFAULT.with({airClient: true})); + + const first = stream.delta("plan", "# Plan\n"); + const completed = await stream.completed("plan", "# Plan\n1. Step"); + + expect(first).toMatchObject({sessionUpdate: "agent_message_chunk", content: {text: "# Plan\n"}}); + expect(completed).toMatchObject({ + text: "# Plan\n1. Step", + update: {sessionUpdate: "agent_message_chunk", content: {text: "1. Step"}}, + }); + }); + + it("sends the whole plan once to another client without plan updates", async () => { + const {stream} = createStream(ClientCapabilities.DEFAULT); + + const first = stream.delta("plan", "# Plan\n"); + const completed = await stream.completed("plan", "# Plan\n1. Step"); + + expect(first).toBeNull(); + expect(completed).toEqual({ + text: "# Plan\n1. Step", + update: {sessionUpdate: "agent_message_chunk", messageId: "plan", content: {type: "text", text: "# Plan\n1. Step"}}, + }); + }); +}); diff --git a/src/__tests__/PendingNotificationBuffer.test.ts b/src/__tests__/PendingNotificationBuffer.test.ts new file mode 100644 index 00000000..d2f8ed0a --- /dev/null +++ b/src/__tests__/PendingNotificationBuffer.test.ts @@ -0,0 +1,56 @@ +import {describe, expect, it, vi} from "vitest"; +import type {ServerNotification} from "../app-server"; +import {logger} from "../Logger"; +import {PendingNotificationBuffer} from "../subagents/PendingNotificationBuffer"; + +function delta(itemId: string, text: string): ServerNotification { + return {method: "item/agentMessage/delta", params: {threadId: "child", turnId: "t", itemId, delta: text}}; +} + +describe("PendingNotificationBuffer", () => { + it("merges adjacent deltas of the same item and keeps the order", () => { + const buffer = new PendingNotificationBuffer("child"); + buffer.push(delta("a", "Hel")); + buffer.push(delta("a", "lo")); + buffer.push(delta("b", "!")); + buffer.push(delta("a", "?")); + + expect(buffer.take().map(notification => notification.params)).toEqual([ + {threadId: "child", turnId: "t", itemId: "a", delta: "Hello"}, + {threadId: "child", turnId: "t", itemId: "b", delta: "!"}, + {threadId: "child", turnId: "t", itemId: "a", delta: "?"}, + ]); + }); + + it("does not change the notification objects that the caller pushed", () => { + const buffer = new PendingNotificationBuffer("child"); + const first = delta("a", "Hel"); + const second = delta("a", "lo"); + buffer.push(first); + buffer.push(second); + + expect(first).toEqual(delta("a", "Hel")); + expect(second).toEqual(delta("a", "lo")); + expect((buffer.take()[0]!.params as {delta: string}).delta).toBe("Hello"); + }); + + it("keeps thousands of deltas without a loss", () => { + const buffer = new PendingNotificationBuffer("child"); + for (let index = 0; index < 5000; index++) buffer.push(delta("a", "x")); + + const taken = buffer.take(); + expect(taken).toHaveLength(1); + expect((taken[0]!.params as {delta: string}).delta).toHaveLength(5000); + }); + + it("drops and logs only when the byte cap is hit", () => { + const log = vi.spyOn(logger, "log").mockImplementation(() => {}); + const buffer = new PendingNotificationBuffer("child", 300); + buffer.push(delta("a", "x".repeat(100))); + buffer.push(delta("a", "y".repeat(300))); + + expect(buffer.size).toBe(1); + expect(log).toHaveBeenCalledTimes(1); + log.mockRestore(); + }); +}); diff --git a/src/__tests__/TerminalOutputMode.test.ts b/src/__tests__/TerminalOutputMode.test.ts deleted file mode 100644 index 79beddf4..00000000 --- a/src/__tests__/TerminalOutputMode.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { resolveTerminalOutputMode } from "../TerminalOutputMode"; - -describe("resolveTerminalOutputMode", () => { - it("prefers terminal_output_delta when both modes are advertised", () => { - expect(resolveTerminalOutputMode({ - _meta: { - terminal_output: true, - terminal_output_delta: true, - }, - })).toBe("terminal_output_delta"); - }); - - it("uses legacy terminal_output_delta when only it is advertised", () => { - expect(resolveTerminalOutputMode({ - _meta: { - terminal_output_delta: true, - }, - })).toBe("terminal_output_delta"); - }); - - it("uses terminal_output when it is the only advertised mode", () => { - expect(resolveTerminalOutputMode({ - _meta: { - terminal_output: true, - }, - })).toBe("terminal_output"); - }); - - it("keeps legacy terminal_output_delta when capabilities are absent", () => { - expect(resolveTerminalOutputMode(null)).toBe("terminal_output_delta"); - expect(resolveTerminalOutputMode({})).toBe("terminal_output_delta"); - }); -}); diff --git a/src/__tests__/ToolCallReportingConnection.test.ts b/src/__tests__/ToolCallReportingConnection.test.ts new file mode 100644 index 00000000..ebeffb4e --- /dev/null +++ b/src/__tests__/ToolCallReportingConnection.test.ts @@ -0,0 +1,94 @@ +import * as acp from "@agentclientprotocol/sdk"; +import {describe, expect, it, vi} from "vitest"; +import type {AcpClientConnection} from "../ACPSessionConnection"; +import {ToolCallReportingConnection} from "../ToolCallReportingConnection"; + +function createClient() { + const notify = vi.fn().mockResolvedValue(undefined); + const request = vi.fn().mockResolvedValue({outcome: {outcome: "cancelled"}}); + const client = {notify, request} as unknown as AcpClientConnection; + return {client, notify, request}; +} + +describe("ToolCallReportingConnection", () => { + it("sends only the changed fields of a tool call update from any emission site", async () => { + const {client, notify} = createClient(); + const connection = new ToolCallReportingConnection(client).asClientConnection(); + await connection.notify(acp.methods.client.session.update, { + sessionId: "s", + update: {sessionUpdate: "tool_call", toolCallId: "t", title: "Guardian Review", status: "in_progress"}, + }); + await connection.notify(acp.methods.client.session.update, { + sessionId: "s", + update: {sessionUpdate: "tool_call_update", toolCallId: "t", title: "Guardian Review", status: "completed"}, + }); + + expect(notify.mock.calls[1]).toEqual([acp.methods.client.session.update, { + sessionId: "s", + update: {sessionUpdate: "tool_call_update", toolCallId: "t", status: "completed"}, + }]); + }); + + it("drops an update that repeats every reported field", async () => { + const {client, notify} = createClient(); + const connection = new ToolCallReportingConnection(client).asClientConnection(); + const update = {sessionUpdate: "tool_call", toolCallId: "t", title: "Search", status: "in_progress"} as const; + await connection.notify(acp.methods.client.session.update, {sessionId: "s", update}); + await connection.notify(acp.methods.client.session.update, { + sessionId: "s", + update: {...update, sessionUpdate: "tool_call_update"}, + }); + + expect(notify).toHaveBeenCalledTimes(1); + }); + + it("counts the permission request tool call as a report", async () => { + const {client, notify, request} = createClient(); + request.mockResolvedValue({outcome: {outcome: "selected", optionId: "allow"}}); + const connection = new ToolCallReportingConnection(client).asClientConnection(); + await connection.notify(acp.methods.client.session.update, { + sessionId: "s", + update: {sessionUpdate: "tool_call", toolCallId: "t", title: "mcp.server.tool", status: "in_progress"}, + }); + await connection.request(acp.methods.client.session.requestPermission, { + sessionId: "s", + toolCall: {toolCallId: "t", status: "pending"}, + options: [], + }); + await connection.notify(acp.methods.client.session.update, { + sessionId: "s", + update: {sessionUpdate: "tool_call_update", toolCallId: "t", status: "in_progress"}, + }); + + expect(notify.mock.calls[1]![1]).toEqual({ + sessionId: "s", + update: {sessionUpdate: "tool_call_update", toolCallId: "t", status: "in_progress"}, + }); + }); + + for (const [name, answer] of [ + ["cancelled", (request: ReturnType) => request.mockResolvedValue({outcome: {outcome: "cancelled"}})], + ["failed", (request: ReturnType) => request.mockRejectedValue(new Error("closed"))], + ] as const) { + it(`sends the fields of a ${name} permission request again in the next update`, async () => { + const {client, notify, request} = createClient(); + answer(request); + const connection = new ToolCallReportingConnection(client).asClientConnection(); + await connection.request(acp.methods.client.session.requestPermission, { + sessionId: "s", + toolCall: {toolCallId: "t", title: "Run command", status: "pending", rawInput: {command: "npm test"}}, + options: [], + }).catch(() => undefined); + const update = { + sessionUpdate: "tool_call_update", + toolCallId: "t", + title: "Run command", + status: "failed", + rawInput: {command: "npm test"}, + } as const; + await connection.notify(acp.methods.client.session.update, {sessionId: "s", update}); + + expect(notify.mock.calls[0]![1]).toEqual({sessionId: "s", update}); + }); + } +}); diff --git a/src/__tests__/ToolCallReports.test.ts b/src/__tests__/ToolCallReports.test.ts new file mode 100644 index 00000000..d43d81d7 --- /dev/null +++ b/src/__tests__/ToolCallReports.test.ts @@ -0,0 +1,221 @@ +import {describe, expect, it} from "vitest"; +import {ToolCallReports} from "../ToolCallReports"; + +describe("ToolCallReports", () => { + it("removes fields that did not change since the start", () => { + const reports = new ToolCallReports(); + reports.prepare("s", { + sessionUpdate: "tool_call", + toolCallId: "web-1", + kind: "search", + title: "Web search: acp", + status: "in_progress", + rawInput: {query: "acp"}, + _meta: {codex: {tool: "web"}}, + }); + + expect(reports.prepare("s", { + sessionUpdate: "tool_call_update", + toolCallId: "web-1", + title: "Web search: acp", + status: "completed", + rawInput: {query: "acp"}, + _meta: {codex: {tool: "web"}}, + })).toEqual({ + sessionUpdate: "tool_call_update", + toolCallId: "web-1", + status: "completed", + }); + }); + + it("keeps every _meta key for a client that is not AIR, because ACP defines no merge for _meta", () => { + const reports = new ToolCallReports(); + reports.compareMeta = false; + reports.prepare("s", { + sessionUpdate: "tool_call", + toolCallId: "cmd-1", + title: "npm test", + status: "in_progress", + _meta: {terminal_info: {cwd: "/w", terminal_id: "cmd-1"}, codex: {tool: "exec"}}, + }); + + expect(reports.prepare("s", { + sessionUpdate: "tool_call_update", + toolCallId: "cmd-1", + title: "npm test", + status: "completed", + _meta: {terminal_info: {cwd: "/w", terminal_id: "cmd-1"}, codex: {tool: "exec"}}, + })).toEqual({ + sessionUpdate: "tool_call_update", + toolCallId: "cmd-1", + status: "completed", + _meta: {terminal_info: {cwd: "/w", terminal_id: "cmd-1"}, codex: {tool: "exec"}}, + }); + expect(reports.prepare("s", { + sessionUpdate: "tool_call_update", + toolCallId: "cmd-1", + _meta: {codex: {tool: "exec"}}, + })).toEqual({sessionUpdate: "tool_call_update", toolCallId: "cmd-1", _meta: {codex: {tool: "exec"}}}); + }); + + it("keeps fields that changed since the start", () => { + const reports = new ToolCallReports(); + reports.prepare("s", { + sessionUpdate: "tool_call", + toolCallId: "web-1", + title: "Web search", + status: "in_progress", + rawInput: {query: ""}, + _meta: {codex: {tool: "web"}, other: 1}, + }); + + expect(reports.prepare("s", { + sessionUpdate: "tool_call_update", + toolCallId: "web-1", + title: "Web search: acp", + status: "completed", + rawInput: {query: "acp"}, + _meta: {codex: {tool: "web"}, other: 2}, + })).toEqual({ + sessionUpdate: "tool_call_update", + toolCallId: "web-1", + title: "Web search: acp", + status: "completed", + rawInput: {query: "acp"}, + _meta: {other: 2}, + }); + }); + + it("drops an update that carries no change", () => { + const reports = new ToolCallReports(); + reports.prepare("s", { + sessionUpdate: "tool_call", + toolCallId: "search-1", + title: "Search", + status: "in_progress", + locations: [{path: "/a"}], + }); + + expect(reports.prepare("s", { + sessionUpdate: "tool_call_update", + toolCallId: "search-1", + title: "Search", + status: "in_progress", + locations: [{path: "/a"}], + })).toBeNull(); + }); + + it("never compares appended output chunks", () => { + const reports = new ToolCallReports(); + reports.prepare("s", {sessionUpdate: "tool_call", toolCallId: "cmd-1", title: "ls", status: "in_progress"}); + const chunk = { + sessionUpdate: "tool_call_update" as const, + toolCallId: "cmd-1", + _meta: {terminal_output_delta: {data: "same\n", terminal_id: "cmd-1"}}, + }; + + expect(reports.prepare("s", chunk)).toEqual(chunk); + expect(reports.prepare("s", chunk)).toEqual(chunk); + }); + + it("sends every field of a tool call that it does not know", () => { + const reports = new ToolCallReports(); + const update = { + sessionUpdate: "tool_call_update" as const, + toolCallId: "unknown", + name: "exec_command", + status: "completed" as const, + }; + + expect(reports.prepare("s", update)).toEqual(update); + }); + + it("keeps only the small fields of a finished tool call and separates sessions", () => { + const reports = new ToolCallReports(); + const start = { + sessionUpdate: "tool_call" as const, + toolCallId: "t", + title: "A", + status: "in_progress" as const, + rawInput: {command: "ls"}, + }; + reports.prepare("s", start); + reports.prepare("s", {sessionUpdate: "tool_call_update", toolCallId: "t", status: "completed"}); + + expect(reports.prepare("s", {sessionUpdate: "tool_call_update", toolCallId: "t", title: "A"})).toBeNull(); + const input = {sessionUpdate: "tool_call_update" as const, toolCallId: "t", rawInput: {command: "ls"}}; + expect(reports.prepare("s", input)).toEqual(input); + reports.prepare("s", start); + const repeated = {sessionUpdate: "tool_call_update" as const, toolCallId: "t", title: "A"}; + expect(reports.prepare("other", repeated)).toEqual(repeated); + }); +}); + +describe("ToolCallReports turn end", () => { + it("forgets open tool calls of the ended session only", () => { + const reports = new ToolCallReports(); + const start = (sessionId: string) => reports.prepare(sessionId, { + sessionUpdate: "tool_call", + toolCallId: "tool-1", + title: "Run", + status: "in_progress", + }); + const repeat = (sessionId: string) => reports.prepare(sessionId, { + sessionUpdate: "tool_call_update", + toolCallId: "tool-1", + title: "Run", + }); + start("ended"); + start("other"); + + reports.releaseOpen("ended"); + + expect(repeat("ended")).toEqual({sessionUpdate: "tool_call_update", toolCallId: "tool-1", title: "Run"}); + expect(repeat("other")).toBeNull(); + }); +}); + +describe("ToolCallReports late output", () => { + it("drops output chunks that arrive after the tool call finished", () => { + const reports = new ToolCallReports(); + reports.prepare("s", {sessionUpdate: "tool_call", toolCallId: "cmd-1", title: "ls", status: "in_progress"}); + reports.prepare("s", {sessionUpdate: "tool_call_update", toolCallId: "cmd-1", status: "completed"}); + + expect(reports.prepare("s", { + sessionUpdate: "tool_call_update", + toolCallId: "cmd-1", + _meta: {terminal_output_delta: {data: "late\n", terminal_id: "cmd-1"}}, + })).toBeNull(); + expect(reports.prepare("s", { + sessionUpdate: "tool_call_update", + toolCallId: "cmd-1", + _meta: {mcp_output_delta: {data: "late"}}, + })).toBeNull(); + }); + + it("keeps the output of a completion report after a start with the final status, without the status", () => { + const reports = new ToolCallReports(); + reports.prepare("s", {sessionUpdate: "tool_call", toolCallId: "cmd-1", title: "ls", status: "completed"}); + const output = {terminal_output_delta: {data: "a.txt\n", terminal_id: "cmd-1"}}; + + expect(reports.prepare("s", { + sessionUpdate: "tool_call_update", + toolCallId: "cmd-1", + status: "completed", + _meta: output, + })).toEqual({sessionUpdate: "tool_call_update", toolCallId: "cmd-1", _meta: output}); + }); + + it("accepts output again when the tool call id starts a new tool call", () => { + const reports = new ToolCallReports(); + reports.prepare("s", {sessionUpdate: "tool_call", toolCallId: "cmd-1", title: "ls", status: "completed"}); + reports.prepare("s", {sessionUpdate: "tool_call", toolCallId: "cmd-1", title: "ls", status: "in_progress"}); + const chunk = { + sessionUpdate: "tool_call_update" as const, + toolCallId: "cmd-1", + _meta: {terminal_output_delta: {data: "new\n", terminal_id: "cmd-1"}}, + }; + + expect(reports.prepare("s", chunk)).toEqual(chunk); + }); +}); diff --git a/src/__tests__/acp-test-utils.ts b/src/__tests__/acp-test-utils.ts index 3a0d9b04..cfd1c0eb 100644 --- a/src/__tests__/acp-test-utils.ts +++ b/src/__tests__/acp-test-utils.ts @@ -1,6 +1,7 @@ import * as acp from "@agentclientprotocol/sdk"; import type {CreateElicitationResponse, McpServerStdio, RequestPermissionResponse} from "@agentclientprotocol/sdk"; import {CodexAcpClient} from '../CodexAcpClient'; +import {ToolCallReports} from "../ToolCallReports"; import {CodexAppServerClient, type CodexConnectionEvent} from '../CodexAppServerClient'; import {type CodexConnection, startCodexConnection} from "../CodexJsonRpcConnection"; import {CodexAcpServer, type CodexProcessState, type SessionState} from "../CodexAcpServer"; @@ -18,6 +19,7 @@ import {CodexSubagentEventRouter} from "../subagents/CodexSubagentEventRouter"; import {CodexBackgroundTerminalTasks} from "../async-tasks/CodexBackgroundTerminalTasks"; import {CodexSessionCompactions} from "../CodexSessionCompactions"; import {AUTH_STATUS_UPDATE_METHOD} from "../AuthStatusMeta"; +import {ClientCapabilities} from "../tool-calls/ClientCapabilities"; export type MethodCallEvent = { method: string; args: any[] }; @@ -418,12 +420,12 @@ export function createTestSessionState(overrides?: Partial): Sessi collaborationMode: DEFAULT_COLLABORATION_MODE, fastModeEnabled: false, currentModelSupportsFast: false, - terminalOutputMode: "terminal_output_delta", - terminalOutputDeltaSupported: false, + clientCapabilities: ClientCapabilities.DEFAULT.with({airClient: true, terminalOutputDelta: true}), goalRevision: 0, sessionTitle: null, sessionTitleSource: "unknown", compactions: new CodexSessionCompactions(), + toolCallReports: new ToolCallReports(), subagents: new CodexSubagentEventRouter( sessionId, false, diff --git a/src/__tests__/tool-calls/data/tool-calls-air.json b/src/__tests__/tool-calls/data/tool-calls-air.json new file mode 100644 index 00000000..18cf6699 --- /dev/null +++ b/src/__tests__/tool-calls/data/tool-calls-air.json @@ -0,0 +1,362 @@ +{ + "command": [ + { + "sessionUpdate": "tool_call", + "toolCallId": "cmd", + "kind": "execute", + "title": "npm test", + "status": "in_progress", + "content": [ + { + "type": "terminal", + "terminalId": "cmd" + } + ], + "rawInput": { + "command": "/bin/zsh -lc 'npm test'", + "cwd": "/w" + }, + "_meta": { + "terminal_info": { + "cwd": "/w", + "terminal_id": "cmd" + } + } + }, + { + "sessionUpdate": "tool_call_update", + "toolCallId": "cmd", + "_meta": { + "terminal_output_delta": { + "data": "ok\n", + "terminal_id": "cmd" + } + } + }, + { + "sessionUpdate": "tool_call_update", + "toolCallId": "cmd", + "_meta": { + "terminal_input": { + "data": "y", + "terminal_id": "cmd" + } + } + }, + { + "sessionUpdate": "tool_call_update", + "toolCallId": "cmd", + "status": "completed", + "_meta": { + "terminal_exit": { + "exit_code": 0, + "signal": null, + "terminal_id": "cmd" + } + } + } + ], + "read": [ + { + "sessionUpdate": "tool_call", + "toolCallId": "read", + "kind": "read", + "title": "Read file '/w/a.txt'", + "status": "in_progress", + "locations": [ + { + "path": "/w/a.txt" + } + ] + }, + { + "sessionUpdate": "tool_call_update", + "toolCallId": "read", + "status": "completed", + "content": [ + { + "type": "content", + "content": { + "type": "text", + "text": "ok\n" + } + } + ] + } + ], + "mcp": [ + { + "sessionUpdate": "tool_call", + "toolCallId": "mcp", + "kind": "execute", + "title": "mcp.docs.search", + "status": "in_progress", + "rawInput": { + "server": "docs", + "tool": "search", + "arguments": { + "q": "acp" + } + }, + "_meta": { + "is_mcp_tool_call": true + } + }, + { + "sessionUpdate": "tool_call_update", + "toolCallId": "mcp" + }, + { + "sessionUpdate": "tool_call_update", + "toolCallId": "mcp", + "status": "completed", + "rawInput": { + "server": "docs", + "tool": "search", + "arguments": { + "q": "acp" + } + }, + "rawOutput": { + "result": { + "content": [ + { + "type": "text", + "text": "hit" + } + ], + "structuredContent": { + "hits": 1 + }, + "_meta": null + }, + "error": null + } + } + ], + "dynamicTool": [ + { + "sessionUpdate": "tool_call_update", + "toolCallId": "dyn", + "name": "list_apps", + "status": "completed", + "content": [ + { + "type": "content", + "content": { + "type": "text", + "text": "Done" + } + } + ] + } + ], + "webSearch": [ + { + "sessionUpdate": "tool_call", + "toolCallId": "web", + "kind": "search", + "title": "Web search: acp", + "status": "completed", + "rawInput": { + "query": "acp", + "action": null + } + } + ], + "imageView": [ + { + "sessionUpdate": "tool_call", + "toolCallId": "img", + "name": "view_image", + "kind": "read", + "title": "View Image /w/a.png", + "status": "completed", + "content": [ + { + "type": "content", + "content": { + "type": "resource_link", + "name": "/w/a.png", + "uri": "/w/a.png" + } + } + ], + "locations": [ + { + "path": "/w/a.png" + } + ], + "rawInput": { + "path": "/w/a.png" + } + } + ], + "imageGeneration": [ + { + "sessionUpdate": "tool_call", + "toolCallId": "gen", + "kind": "other", + "title": "Image generation", + "status": "completed", + "content": [ + { + "type": "content", + "content": { + "type": "text", + "text": "Revised prompt: A square" + } + }, + { + "type": "content", + "content": { + "type": "image", + "data": "AAAA", + "mimeType": "image/png", + "uri": "/w/square.png" + } + } + ] + } + ], + "collab": [ + { + "sessionUpdate": "tool_call", + "toolCallId": "collab", + "kind": "other", + "title": "spawnAgent", + "status": "completed", + "rawInput": { + "prompt": "Find the weather.", + "senderThreadId": "root", + "receiverThreadIds": [ + "child" + ], + "agentsStates": { + "child": { + "status": "completed", + "message": null + } + }, + "model": null, + "reasoningEffort": null + }, + "_meta": { + "jetbrains": { + "air": { + "version": 1, + "subagent": true + } + } + } + } + ], + "subagentActivity": [ + { + "sessionUpdate": "tool_call", + "toolCallId": "act", + "kind": "other", + "title": "Start subagent child", + "status": "in_progress", + "rawInput": { + "agentThreadId": "child", + "agentPath": "/root/child", + "activityKind": "started" + }, + "_meta": { + "jetbrains": { + "air": { + "version": 1, + "subagent": true + } + } + } + } + ], + "fuzzySearch": [ + { + "sessionUpdate": "tool_call", + "toolCallId": "fuzzyFileSearch.f", + "kind": "search", + "title": "Search for 'App'", + "status": "in_progress", + "locations": [], + "rawInput": { + "query": "App" + } + } + ], + "guardian": [ + { + "sessionUpdate": "tool_call", + "toolCallId": "guardian_assessment:r", + "kind": "think", + "title": "Guardian Review", + "status": "completed", + "content": [ + { + "type": "content", + "content": { + "type": "text", + "text": "Status: Approved\nRisk: low\nRationale: Safe." + } + } + ], + "rawInput": { + "action": { + "type": "command", + "source": "shell", + "command": "ls", + "cwd": "/w" + } + } + } + ], + "compaction": [ + { + "sessionUpdate": "tool_call", + "toolCallId": "compact", + "kind": "think", + "title": "Compact conversation", + "status": "completed", + "_meta": { + "jetbrains": { + "air": { + "version": 1, + "contextCompaction": { + "version": 1 + } + } + } + } + } + ], + "elicitation": [ + { + "sessionUpdate": "tool_call_update", + "toolCallId": "ask", + "status": "completed", + "rawOutput": { + "action": "accept" + } + } + ], + "mcpStartup": [ + { + "sessionUpdate": "tool_call", + "toolCallId": "mcp_startup.db", + "kind": "other", + "title": "mcp__db__startup", + "status": "failed", + "content": [ + { + "type": "content", + "content": { + "type": "text", + "text": "[codex-acp forwarded startup error] MCP server `db` failed to start: boom" + } + } + ] + } + ] +} diff --git a/src/__tests__/tool-calls/data/tool-calls-zed.json b/src/__tests__/tool-calls/data/tool-calls-zed.json new file mode 100644 index 00000000..4d8c06d9 --- /dev/null +++ b/src/__tests__/tool-calls/data/tool-calls-zed.json @@ -0,0 +1,360 @@ +{ + "command": [ + { + "sessionUpdate": "tool_call", + "toolCallId": "cmd", + "kind": "execute", + "title": "npm test", + "status": "in_progress", + "content": [ + { + "type": "terminal", + "terminalId": "cmd" + } + ], + "rawInput": { + "command": "/bin/zsh -lc 'npm test'", + "cwd": "/w" + }, + "_meta": { + "terminal_info": { + "cwd": "/w", + "terminal_id": "cmd" + } + } + }, + { + "sessionUpdate": "tool_call_update", + "toolCallId": "cmd", + "_meta": { + "terminal_output": { + "data": "ok\n", + "terminal_id": "cmd" + } + } + }, + { + "sessionUpdate": "tool_call_update", + "toolCallId": "cmd", + "_meta": { + "terminal_output": { + "data": "\ny\n", + "terminal_id": "cmd" + } + } + }, + { + "sessionUpdate": "tool_call_update", + "toolCallId": "cmd", + "status": "completed", + "rawOutput": { + "formatted_output": "ok\n", + "exit_code": 0 + }, + "_meta": { + "terminal_exit": { + "exit_code": 0, + "signal": null, + "terminal_id": "cmd" + } + } + } + ], + "read": [ + { + "sessionUpdate": "tool_call", + "toolCallId": "read", + "kind": "read", + "title": "Read file '/w/a.txt'", + "status": "in_progress", + "locations": [ + { + "path": "/w/a.txt" + } + ] + }, + { + "sessionUpdate": "tool_call_update", + "toolCallId": "read", + "status": "completed", + "rawOutput": { + "formatted_output": "ok\n", + "exit_code": 0 + } + } + ], + "mcp": [ + { + "sessionUpdate": "tool_call", + "toolCallId": "mcp", + "kind": "execute", + "title": "mcp.docs.search", + "status": "in_progress", + "rawInput": { + "server": "docs", + "tool": "search", + "arguments": { + "q": "acp" + } + }, + "_meta": { + "is_mcp_tool_call": true + } + }, + { + "sessionUpdate": "tool_call_update", + "toolCallId": "mcp", + "_meta": { + "mcp_output_delta": { + "data": "line 1" + } + } + }, + { + "sessionUpdate": "tool_call_update", + "toolCallId": "mcp", + "status": "completed", + "rawInput": { + "server": "docs", + "tool": "search", + "arguments": { + "q": "acp" + } + }, + "rawOutput": { + "result": { + "content": [ + { + "type": "text", + "text": "hit" + } + ], + "structuredContent": { + "hits": 1 + }, + "_meta": null + }, + "error": null + } + } + ], + "dynamicTool": [ + { + "sessionUpdate": "tool_call_update", + "toolCallId": "dyn", + "name": "list_apps", + "status": "completed", + "content": [ + { + "type": "content", + "content": { + "type": "text", + "text": "Done" + } + } + ] + } + ], + "webSearch": [ + { + "sessionUpdate": "tool_call", + "toolCallId": "web", + "kind": "search", + "title": "Web search: acp", + "status": "completed", + "rawInput": { + "query": "acp", + "action": null + } + } + ], + "imageView": [ + { + "sessionUpdate": "tool_call", + "toolCallId": "img", + "name": "view_image", + "kind": "read", + "title": "View Image /w/a.png", + "status": "completed", + "content": [ + { + "type": "content", + "content": { + "type": "resource_link", + "name": "/w/a.png", + "uri": "/w/a.png" + } + } + ], + "locations": [ + { + "path": "/w/a.png" + } + ], + "rawInput": { + "path": "/w/a.png" + } + } + ], + "imageGeneration": [ + { + "sessionUpdate": "tool_call", + "toolCallId": "gen", + "kind": "other", + "title": "Image generation", + "status": "completed", + "content": [ + { + "type": "content", + "content": { + "type": "text", + "text": "Revised prompt: A square" + } + }, + { + "type": "content", + "content": { + "type": "image", + "data": "AAAA", + "mimeType": "image/png", + "uri": "/w/square.png" + } + } + ], + "rawOutput": { + "status": "completed", + "revisedPrompt": "A square", + "result": "AAAA", + "savedPath": "/w/square.png" + } + } + ], + "collab": [ + { + "sessionUpdate": "tool_call", + "toolCallId": "collab", + "kind": "other", + "title": "spawnAgent", + "status": "completed", + "rawInput": { + "prompt": "Find the weather.", + "senderThreadId": "root", + "receiverThreadIds": [ + "child" + ], + "agentsStates": { + "child": { + "status": "completed", + "message": null + } + }, + "model": null, + "reasoningEffort": null, + "status": "completed" + } + } + ], + "subagentActivity": [ + { + "sessionUpdate": "tool_call", + "toolCallId": "act", + "kind": "other", + "title": "Start subagent child", + "status": "in_progress", + "rawInput": { + "agentThreadId": "child", + "agentPath": "/root/child", + "activityKind": "started" + } + } + ], + "fuzzySearch": [ + { + "sessionUpdate": "tool_call", + "toolCallId": "fuzzyFileSearch.f", + "kind": "search", + "title": "Search for 'App'", + "status": "in_progress", + "locations": [], + "rawInput": { + "query": "App" + } + } + ], + "guardian": [ + { + "sessionUpdate": "tool_call", + "toolCallId": "guardian_assessment:r", + "kind": "think", + "title": "Guardian Review", + "status": "completed", + "content": [ + { + "type": "content", + "content": { + "type": "text", + "text": "Status: Approved\nAction: shell ls\nRisk: low\nRationale: Safe." + } + } + ], + "rawInput": { + "threadId": "s", + "turnId": "t", + "startedAtMs": 0, + "completedAtMs": 1, + "reviewId": "r", + "targetItemId": null, + "decisionSource": "agent", + "review": { + "status": "approved", + "riskLevel": "low", + "userAuthorization": null, + "rationale": "Safe." + }, + "action": { + "type": "command", + "source": "shell", + "command": "ls", + "cwd": "/w" + } + } + } + ], + "compaction": [ + { + "sessionUpdate": "tool_call", + "toolCallId": "compact", + "kind": "think", + "title": "Compact conversation", + "status": "completed" + } + ], + "elicitation": [ + { + "sessionUpdate": "tool_call_update", + "toolCallId": "ask", + "status": "completed", + "rawOutput": { + "action": "accept" + } + } + ], + "mcpStartup": [ + { + "sessionUpdate": "tool_call", + "toolCallId": "mcp_startup.db", + "kind": "other", + "title": "mcp__db__startup", + "status": "failed", + "content": [ + { + "type": "content", + "content": { + "type": "text", + "text": "[codex-acp forwarded startup error] MCP server `db` failed to start: boom" + } + } + ] + } + ] +} diff --git a/src/__tests__/tool-calls/tool-call-contract.test.ts b/src/__tests__/tool-calls/tool-call-contract.test.ts new file mode 100644 index 00000000..b4aa7d57 --- /dev/null +++ b/src/__tests__/tool-calls/tool-call-contract.test.ts @@ -0,0 +1,181 @@ +import {describe, expect, it} from "vitest"; +import {AcpToolCallRenderer} from "../../tool-calls/AcpToolCallRenderer"; +import {ClientCapabilities} from "../../tool-calls/ClientCapabilities"; +import type {ToolFacts} from "../../tool-calls/ToolFacts"; +import {CollabAgentReporter} from "../../tool-calls/reporters/CollabAgentReporter"; +import {CommandReporter} from "../../tool-calls/reporters/CommandReporter"; +import {CompactionReporter} from "../../tool-calls/reporters/CompactionReporter"; +import {DynamicToolReporter} from "../../tool-calls/reporters/DynamicToolReporter"; +import {ElicitationReporter} from "../../tool-calls/reporters/ElicitationReporter"; +import {FuzzySearchReporter} from "../../tool-calls/reporters/FuzzySearchReporter"; +import {GuardianReporter} from "../../tool-calls/reporters/GuardianReporter"; +import {ImageGenerationReporter} from "../../tool-calls/reporters/ImageGenerationReporter"; +import {ImageViewReporter} from "../../tool-calls/reporters/ImageViewReporter"; +import {McpStartupReporter} from "../../tool-calls/reporters/McpStartupReporter"; +import {McpToolReporter} from "../../tool-calls/reporters/McpToolReporter"; +import {SubagentActivityReporter} from "../../tool-calls/reporters/SubagentActivityReporter"; +import {WebSearchReporter} from "../../tool-calls/reporters/WebSearchReporter"; + +const AIR = ClientCapabilities.from({ + _meta: { + terminal_output_delta: true, + jetbrains: {air: {version: 1, capabilities: ["rawInputRendering", "planContentDelta", "diffPatch"]}}, + }, +}); +const ZED = ClientCapabilities.from({_meta: {terminal_output: true}}); +const AIR_WITHOUT_RAW_INPUT_RENDERING = ClientCapabilities.from({ + _meta: {terminal_output_delta: true, jetbrains: {air: {version: 1, capabilities: []}}}, +}); + +const command = { + type: "commandExecution", id: "cmd", pluginId: null, scriptPath: null, command: "/bin/zsh -lc 'npm test'", + cwd: "/w", processId: null, source: "agent", status: "completed", commandActions: [], + aggregatedOutput: "ok\n", exitCode: 0, durationMs: 1, +} as const; +const read = { + ...command, id: "read", command: "cat a.txt", + commandActions: [{type: "read", command: "cat a.txt", name: "a.txt", path: "/w/a.txt"}], +} as const; +const mcp = { + type: "mcpToolCall", id: "mcp", server: "docs", tool: "search", status: "completed", arguments: {q: "acp"}, + appContext: null, readOnlyHint: null, pluginId: null, durationMs: 1, + result: {content: [{type: "text", text: "hit"}], structuredContent: {hits: 1}, _meta: null}, error: null, +} as const; +const collab = { + type: "collabAgentToolCall", id: "collab", tool: "spawnAgent", status: "completed", senderThreadId: "root", + receiverThreadIds: ["child"], prompt: "Find the weather.", model: null, reasoningEffort: null, + agentsStates: {child: {status: "completed", message: null}}, +} as const; +const guardianEvent = { + threadId: "s", turnId: "t", startedAtMs: 0, completedAtMs: 1, reviewId: "r", targetItemId: null, + decisionSource: "agent", + review: {status: "approved", riskLevel: "low", userAuthorization: null, rationale: "Safe."}, + action: {type: "command", source: "shell", command: "ls", cwd: "/w"}, +} as const; + +/** One report of each tool kind, as the reporters produce it. */ +function reportsOfEachKind(): Record { + const commands = new CommandReporter(); + const reads = new CommandReporter(); + return { + command: [ + commands.started({...command, status: "inProgress", aggregatedOutput: null, exitCode: null} as never), + commands.outputDelta("cmd", "ok\n")!, + commands.terminalInput("cmd", "y")!, + commands.completed(command as never), + ], + read: [reads.started({...read, status: "inProgress"} as never), reads.completed(read as never)], + mcp: [McpToolReporter.started({...mcp, status: "inProgress", result: null} as never), + McpToolReporter.progress("mcp", "line 1\n"), McpToolReporter.completed(mcp as never)], + dynamicTool: [DynamicToolReporter.completed({ + type: "dynamicToolCall", id: "dyn", tool: "list_apps", namespace: null, status: "completed", + arguments: {}, contentItems: [{type: "inputText", text: "Done"}], success: true, durationMs: 1, + } as never)], + webSearch: [WebSearchReporter.history({type: "webSearch", id: "web", query: "acp", action: null} as never)], + imageView: [ImageViewReporter.viewed({type: "imageView", id: "img", path: "/w/a.png"} as never)], + imageGeneration: [ImageGenerationReporter.whole({ + type: "imageGeneration", id: "gen", status: "completed", revisedPrompt: "A square", result: "AAAA", + savedPath: "/w/square.png", failure: null, + } as never)], + collab: [CollabAgentReporter.started(collab as never)], + subagentActivity: [SubagentActivityReporter.activity({ + type: "subAgentActivity", id: "act", kind: "started", agentThreadId: "child", agentPath: "/root/child", + } as never, "in_progress", "start")], + fuzzySearch: [new FuzzySearchReporter().updated({sessionId: "f", query: "App", files: []} as never)], + guardian: [new GuardianReporter().completed(guardianEvent as never)], + compaction: [CompactionReporter.history({type: "contextCompaction", id: "compact"} as never)], + elicitation: [ElicitationReporter.answered("ask", "accept")], + mcpStartup: McpStartupReporter.failures({ready: [], failed: [{server: "db", error: "boom", failureReason: null}], cancelled: []} as never) + .map(facts => ({...facts, toolCallId: "mcp_startup.db"})), + }; +} + +function render(capabilities: ClientCapabilities): string { + const renderer = new AcpToolCallRenderer(capabilities); + const rendered = Object.fromEntries(Object.entries(reportsOfEachKind()) + .map(([kind, reports]) => [kind, reports.map(facts => renderer.render(facts))])); + return `${JSON.stringify(rendered, null, 2)}\n`; +} + +describe("ACP tool call contract", () => { + it("renders each tool kind for AIR", async () => { + await expect(render(AIR)).toMatchFileSnapshot("data/tool-calls-air.json"); + }); + + it("renders each tool kind for a Zed-like client", async () => { + await expect(render(ZED)).toMatchFileSnapshot("data/tool-calls-zed.json"); + }); + + it("never sends the pre-contract keys to AIR", () => { + const text = render(AIR); + expect(text).not.toContain("formatted_output"); + expect(text).not.toContain("\"codex\""); + }); + + it("never sends an AIR key to Zed", () => { + expect(render(ZED)).not.toContain("jetbrains"); + }); + + it("keeps the Zed terminal conventions and the command output in rawOutput", () => { + const text = render(ZED); + expect(text).toContain("terminal_info"); + expect(text).toContain("\"terminal_output\""); + expect(text).toContain("terminal_exit"); + expect(text).toContain("formatted_output"); + expect(text).not.toContain("\"terminal_input\""); + expect(text).not.toContain("terminal_output_delta"); + }); + + it("sends one display copy of readable input only to AIR without rawInputRendering", () => { + const facts = CollabAgentReporter.started(collab as never); + const zed = new AcpToolCallRenderer(ZED).render(facts); + const air = new AcpToolCallRenderer(AIR).render(facts); + const airWithoutRendering = new AcpToolCallRenderer(AIR_WITHOUT_RAW_INPUT_RENDERING).render(facts); + + expect(airWithoutRendering.content).toEqual([{type: "content", content: {type: "text", text: "Find the weather."}}]); + expect(air).not.toHaveProperty("content"); + expect(air.rawInput).toMatchObject({prompt: "Find the weather."}); + expect(zed).not.toHaveProperty("content"); + expect(zed.rawInput).toMatchObject({prompt: "Find the weather.", status: "completed"}); + }); + + it("shows the question of a standalone elicitation only once for AIR", () => { + const facts = ElicitationReporter.permission({ + threadId: "s", turnId: "t", serverName: "srv", mode: "form", _meta: null, + message: "Pick a value", requestedSchema: {type: "object", properties: {}}, + } as never, false, undefined, () => "ask"); + + expect(new AcpToolCallRenderer(AIR).renderPermissionToolCall(facts)).not.toHaveProperty("content"); + expect(new AcpToolCallRenderer(ZED).renderPermissionToolCall(facts).content) + .toEqual([{type: "content", content: {type: "text", text: "Pick a value"}}]); + }); + + it("sends trimmed MCP progress text to a client that is not AIR, and none to AIR", () => { + const zed = new AcpToolCallRenderer(ZED).render(McpToolReporter.progress("mcp", " line 1\n")); + expect(zed._meta).toEqual({mcp_output_delta: {data: "line 1"}}); + const air = new AcpToolCallRenderer(AIR).render(McpToolReporter.progress("mcp", " line 1\n")); + expect(air).toEqual({sessionUpdate: "tool_call_update", toolCallId: "mcp"}); + }); +}); + +describe("ClientCapabilities", () => { + it("reads the AIR client and the AIR capabilities only from _meta.jetbrains.air", () => { + expect(AIR.airClient).toBe(true); + expect(ZED.airClient).toBe(false); + expect(AIR.air).toEqual({rawInputRendering: true, planContentDelta: true, diffPatch: true}); + expect(ClientCapabilities.from({_meta: {rawInputRendering: true, planContentDelta: true}}).air) + .toEqual({rawInputRendering: false, planContentDelta: false, diffPatch: false}); + }); + + it("selects the terminal channel that the client declares, and terminal_output_delta for any other client", () => { + expect(AIR.terminalOutputKey(true)).toBe("terminal_output_delta"); + expect(AIR.terminalOutputKey(false)).toBe("terminal_output_delta"); + expect(ZED.terminalOutputKey(true)).toBe("terminal_output"); + expect(ZED.terminalOutputKey(false)).toBe("terminal_output_delta"); + expect(ClientCapabilities.from(null).terminalOutputKey(true)).toBe("terminal_output_delta"); + expect(ClientCapabilities.from(null).terminalOutputKey(false)).toBe("terminal_output_delta"); + expect(ClientCapabilities.from({_meta: {terminal_output_delta: true}}).terminalOutputKey(false)) + .toBe("terminal_output_delta"); + expect(ClientCapabilities.from({_meta: {jetbrains: {air: {version: 1}}}}).terminalOutputKey(true)).toBeNull(); + }); +}); diff --git a/src/permissions/CodexApprovalHandler.ts b/src/permissions/CodexApprovalHandler.ts index a62bcb5f..219377a3 100644 --- a/src/permissions/CodexApprovalHandler.ts +++ b/src/permissions/CodexApprovalHandler.ts @@ -27,14 +27,19 @@ import { CODEX_NETWORK_PERMISSION_TITLE, requestPermissionMeta, } from "./metadata"; -import {additionalPermissionsToolCall, commandToolCall, fileChangeToolCall} from "./presentation"; import type {PermissionPromptContext} from "./lifecycle"; +import {AcpToolCallRenderer} from "../tool-calls/AcpToolCallRenderer"; +import {ClientCapabilities} from "../tool-calls/ClientCapabilities"; +import {CommandReporter} from "../tool-calls/reporters/CommandReporter"; +import {FileChangeReporter} from "../tool-calls/reporters/FileChangeReporter"; +import {SandboxPermissionReporter} from "../tool-calls/reporters/SandboxPermissionReporter"; export class CodexApprovalHandler implements ApprovalHandler { constructor( private readonly connection: AcpClientConnection, private readonly permissionContext: PermissionPromptContext, private readonly cancellationSignal?: AbortSignal, + private readonly renderer = new AcpToolCallRenderer(ClientCapabilities.DEFAULT), ) {} async handleCommandExecution( @@ -50,9 +55,14 @@ export class CodexApprovalHandler implements ApprovalHandler { try { const response = await this.requestPermission({ sessionId: params.threadId, - toolCall: commandToolCall(authoritativeParams, this.permissionContext), + toolCall: this.renderer.renderPermissionToolCall(CommandReporter.permission( + authoritativeParams, + this.permissionContext.commandStarted(params.threadId, params.itemId), + this.permissionContext.commandName(params.threadId, params.itemId), + )), options: decisions.map(({option}) => option), - _meta: requestPermissionMeta( + ...requestPermissionMeta( + this.renderer.capabilities.airClient, params.networkApprovalContext ? CODEX_NETWORK_PERMISSION_TITLE : CODEX_COMMAND_PERMISSION_TITLE, params.reason, ), @@ -69,9 +79,16 @@ export class CodexApprovalHandler implements ApprovalHandler { try { const response = await this.requestPermission({ sessionId: params.threadId, - toolCall: fileChangeToolCall(params, this.permissionContext), + toolCall: this.renderer.renderPermissionToolCall(FileChangeReporter.permission( + params, + this.permissionContext.fileChange(params.threadId, params.itemId), + )), options: decisions.map(({option}) => option), - _meta: requestPermissionMeta(CODEX_FILE_CHANGE_PERMISSION_TITLE, params.reason), + ...requestPermissionMeta( + this.renderer.capabilities.airClient, + CODEX_FILE_CHANGE_PERMISSION_TITLE, + params.reason, + ), }); return {decision: this.selectedDecision(response, decisions) ?? "cancel"}; } catch (error) { @@ -86,14 +103,18 @@ export class CodexApprovalHandler implements ApprovalHandler { try { const response = await this.requestPermission({ sessionId: params.threadId, - toolCall: additionalPermissionsToolCall( + toolCall: this.renderer.renderPermissionToolCall(SandboxPermissionReporter.permission( params.itemId, params.cwd, params.environmentId, params.permissions, - ), + )), options: permissionProfileOptions(), - _meta: requestPermissionMeta(CODEX_ADDITIONAL_PERMISSIONS_TITLE, params.reason), + ...requestPermissionMeta( + this.renderer.capabilities.airClient, + CODEX_ADDITIONAL_PERMISSIONS_TITLE, + params.reason, + ), }); return this.permissionsResponse(params.permissions, response); } catch (error) { diff --git a/src/permissions/lifecycle.ts b/src/permissions/lifecycle.ts index c48c2bd5..f1061133 100644 --- a/src/permissions/lifecycle.ts +++ b/src/permissions/lifecycle.ts @@ -24,6 +24,7 @@ export class PermissionLifecycleContext { /** Prompt-scoped permission presentation and MCP correlation state. */ export class PermissionPromptContext { private readonly commandNames = new Map>(); + private readonly startedCommands = new Map>(); private readonly fileChanges = new Map>(); private readonly pendingMcpApprovals = new Map>(); @@ -56,6 +57,11 @@ export class PermissionPromptContext { return this.commandNames.get(threadId)?.get(itemId); } + /** Tells whether the client already received the command tool call. */ + commandStarted(threadId: string, itemId: string): boolean { + return this.startedCommands.get(threadId)?.has(itemId) ?? false; + } + popPendingMcpApproval(threadId: string, serverName: string): string | undefined { const byServer = this.pendingMcpApprovals.get(threadId); if (!byServer) return undefined; @@ -73,6 +79,9 @@ export class PermissionPromptContext { private handleItemStarted(threadId: string, item: ThreadItem): void { if (item.type === "commandExecution") { + const started = this.startedCommands.get(threadId) ?? new Set(); + started.add(item.id); + this.startedCommands.set(threadId, started); const name = commandToolName(item.source); if (name !== undefined) { const byItem = this.commandNames.get(threadId) ?? new Map(); @@ -100,6 +109,9 @@ export class PermissionPromptContext { const byItem = this.commandNames.get(threadId); byItem?.delete(item.id); if (byItem?.size === 0) this.commandNames.delete(threadId); + const started = this.startedCommands.get(threadId); + started?.delete(item.id); + if (started?.size === 0) this.startedCommands.delete(threadId); return; } if (item.type === "fileChange") { @@ -121,6 +133,7 @@ export class PermissionPromptContext { private clearTransientState(threadId: string): void { this.commandNames.delete(threadId); + this.startedCommands.delete(threadId); this.fileChanges.delete(threadId); this.pendingMcpApprovals.delete(threadId); } diff --git a/src/permissions/mcp.ts b/src/permissions/mcp.ts index f3d44056..e765df57 100644 --- a/src/permissions/mcp.ts +++ b/src/permissions/mcp.ts @@ -7,6 +7,8 @@ import type { import {optionPermissionMeta} from "./metadata"; import {McpApprovalOptionId} from "./option-ids"; import {isRecord} from "./json"; +import type {AcpToolCallRenderer} from "../tool-calls/AcpToolCallRenderer"; +import {ElicitationReporter} from "../tool-calls/reporters/ElicitationReporter"; export type PersistValue = "session" | "always"; @@ -36,7 +38,17 @@ export function isMcpToolCallApproval(meta: unknown): boolean { export function buildMcpPermissionOptions( isToolApproval: boolean, persistOptions: Set, + airClient: boolean, ): acp.PermissionOption[] { + const permissionOption = ( + optionId: string, + name: string, + kind: acp.PermissionOptionKind, + description: string, + ): acp.PermissionOption => { + const meta = optionPermissionMeta(airClient, description); + return {optionId, name, kind, ...(meta ? {_meta: meta} : {})}; + }; const options: acp.PermissionOption[] = [permissionOption( isToolApproval ? McpApprovalOptionId.AllowOnce : "accept", "Allow", @@ -89,62 +101,30 @@ export function buildMcpPermissionRequest( params: McpServerElicitationRequestParams, context: McpElicitationContext, nextStandaloneToolCallId: () => string, + renderer: AcpToolCallRenderer, ): {request: acp.RequestPermissionRequest; correlatedCallId: string | undefined} { - const messageContent: acp.ToolCallContent = { - type: "content", - content: {type: "text", text: params.message}, - }; - const options = buildMcpPermissionOptions(context.isToolApproval, context.persistOptions); - if (params.mode === "form" || params.mode === "openai/form") { - if (context.correlatedCallId !== undefined) { - return { - request: { - sessionId, - toolCall: { - toolCallId: context.correlatedCallId, - kind: "execute", - status: "pending", - }, - _meta: {is_mcp_tool_approval: true}, - options, - }, - correlatedCallId: context.correlatedCallId, - }; - } - return { - request: { - sessionId, - toolCall: { - toolCallId: nextStandaloneToolCallId(), - kind: context.isToolApproval ? "execute" : "other", - status: "pending", - title: context.isToolApproval ? "MCP tool call approval" : "Question from MCP server", - content: [messageContent], - rawInput: {serverName: params.serverName, description: params.message, schema: params.requestedSchema}, - }, - ...(context.isToolApproval ? {_meta: {is_mcp_tool_approval: true}} : {}), - options, - }, - correlatedCallId: undefined, - }; - } - if (params.mode !== "url") { - throw new Error(`Unsupported MCP elicitation mode: ${params.mode}`); - } + const correlatedCallId = params.mode === "form" || params.mode === "openai/form" + ? context.correlatedCallId + : undefined; + const toolCall = renderer.renderPermissionToolCall(ElicitationReporter.permission( + params, + context.isToolApproval, + correlatedCallId, + nextStandaloneToolCallId, + )); + const toolApprovalMeta = context.isToolApproval && params.mode !== "url" ? {_meta: {is_mcp_tool_approval: true}} : {}; return { request: { sessionId, - toolCall: { - toolCallId: `elicitation-${params.elicitationId}`, - kind: "fetch", - status: "pending", - title: "MCP server requests to open a URL", - content: [messageContent], - rawInput: {serverName: params.serverName, description: params.message, url: params.url}, - }, - options, + toolCall, + ...toolApprovalMeta, + options: buildMcpPermissionOptions( + context.isToolApproval, + context.persistOptions, + renderer.capabilities.airClient, + ), }, - correlatedCallId: undefined, + correlatedCallId, }; } @@ -181,16 +161,6 @@ export function convertMcpPermissionResponse( } } -function permissionOption( - optionId: string, - name: string, - kind: acp.PermissionOptionKind, - description: string, -): acp.PermissionOption { - const meta = optionPermissionMeta(description); - return {optionId, name, kind, ...(meta ? {_meta: meta} : {})}; -} - function cancelledResponse(): McpServerElicitationRequestResponse { return {action: "cancel", content: null, _meta: null as JsonValue | null}; } diff --git a/src/permissions/metadata.ts b/src/permissions/metadata.ts index 2583ff78..d0af4f63 100644 --- a/src/permissions/metadata.ts +++ b/src/permissions/metadata.ts @@ -1,4 +1,5 @@ import type * as acp from "@agentclientprotocol/sdk"; +import {AIR_PERMISSION_KEY, airOnlyMeta} from "../AirExtension"; export const CODEX_COMMAND_PERMISSION_TITLE = "Run command?"; export const CODEX_NETWORK_PERMISSION_TITLE = "Allow network access?"; @@ -16,26 +17,30 @@ type OptionPermissionMetadata = { description: string; }; +/** Only AIR gets the permission presentation, in `_meta.jetbrains.air.permission`. */ export function requestPermissionMeta( + airClient: boolean, title: string, reason?: string | null, -): NonNullable { +): Pick { const description = nonBlank(reason); const permission: RequestPermissionMetadata = { version: 1, title, ...(description ? {description} : {}), }; - return {permission}; + const meta = airOnlyMeta(airClient, AIR_PERMISSION_KEY, permission); + return meta ? {_meta: meta} : {}; } export function optionPermissionMeta( + airClient: boolean, description?: string | null, ): acp.PermissionOption["_meta"] | undefined { const normalized = nonBlank(description); if (!normalized) return undefined; const permission: OptionPermissionMetadata = {version: 1, description: normalized}; - return {permission}; + return airOnlyMeta(airClient, AIR_PERMISSION_KEY, permission); } function nonBlank(value?: string | null): string | undefined { diff --git a/src/permissions/plan-review.ts b/src/permissions/plan-review.ts deleted file mode 100644 index fc0f11f6..00000000 --- a/src/permissions/plan-review.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type * as acp from "@agentclientprotocol/sdk"; -import type {CompletedPlan} from "../CodexEventHandler"; - -const IMPLEMENT_PLAN_OPTION_ID = "implement_plan"; -const REVISE_PLAN_OPTION_ID = "revise_plan"; - -export function planImplementationPermissionRequest( - sessionId: string, - plan: CompletedPlan, -): acp.RequestPermissionRequest { - return { - sessionId, - toolCall: { - toolCallId: planImplementationToolCallId(plan), - title: "Implement this plan?", - kind: "switch_mode", - status: "pending", - rawInput: {plan: plan.text}, - }, - options: [ - {optionId: IMPLEMENT_PLAN_OPTION_ID, name: "Yes, implement this plan", kind: "allow_once"}, - { - optionId: REVISE_PLAN_OPTION_ID, - name: "No, and tell Codex what to do differently", - kind: "reject_once", - }, - ], - _meta: {codex: {kind: "plan_review", planItemId: plan.itemId}}, - }; -} - -export function planImplementationApproved(response: acp.RequestPermissionResponse): boolean { - return response.outcome.outcome === "selected" && response.outcome.optionId === IMPLEMENT_PLAN_OPTION_ID; -} - -export function planImplementationToolCallId(plan: CompletedPlan): string { - return `plan-review:${plan.itemId}`; -} diff --git a/src/permissions/presentation.ts b/src/permissions/presentation.ts deleted file mode 100644 index 413d3b20..00000000 --- a/src/permissions/presentation.ts +++ /dev/null @@ -1,164 +0,0 @@ -import type * as acp from "@agentclientprotocol/sdk"; -import type { - AdditionalPermissionProfile, - CommandAction, - CommandExecutionRequestApprovalParams, - FileChangeRequestApprovalParams, - RequestPermissionProfile, - ThreadItem, -} from "../app-server/v2"; -import {stripShellPrefix} from "../CommandUtils"; -import type {PermissionPromptContext} from "./lifecycle"; - -type FileChangeItem = ThreadItem & {type: "fileChange"}; -type CommandPresentationParams = CommandExecutionRequestApprovalParams & { - additionalPermissions?: AdditionalPermissionProfile | null; -}; - -export function commandToolCall( - params: CommandPresentationParams, - permissionContext: PermissionPromptContext, -): acp.ToolCallUpdate { - const name = permissionContext.commandName(params.threadId, params.itemId); - const network = params.networkApprovalContext; - const rawInput = { - ...(params.command ? {command: stripShellPrefix(params.command)} : {}), - ...(params.cwd ? {cwd: params.cwd} : {}), - ...(network?.protocol === "http" || network?.protocol === "https" - ? {url: `${network.protocol}://${network.host}`} - : {}), - ...(params.additionalPermissions ? {additionalPermissions: params.additionalPermissions} : {}), - }; - const additionalPermissionContent = params.additionalPermissions - ? permissionProfileContent(params.additionalPermissions) - : []; - return { - toolCallId: params.itemId, - ...(name !== undefined ? {name} : {}), - kind: "execute", - status: "pending", - title: network - ? `${network.protocol} network access to ${network.host}` - : commandTitle(params.commandActions), - ...(Object.keys(rawInput).length > 0 ? {rawInput} : {}), - ...locationsField(unique([ - ...commandActionPaths(params.commandActions), - ...permissionProfilePaths(params.additionalPermissions), - ])), - ...(network - ? {content: [textContent(`${network.protocol} access to ${network.host}`), ...additionalPermissionContent]} - : additionalPermissionContent.length > 0 - ? {content: additionalPermissionContent} - : {}), - }; -} - -export function fileChangeToolCall( - params: FileChangeRequestApprovalParams, - permissionContext: PermissionPromptContext, -): acp.ToolCallUpdate { - const item = permissionContext.fileChange(params.threadId, params.itemId); - return { - toolCallId: params.itemId, - kind: "edit", - status: "pending", - title: "Edit files", - ...locationsField(fileChangePaths(item)), - }; -} - -export function additionalPermissionsToolCall( - itemId: string, - cwd: string, - environmentId: string | null, - permissions: RequestPermissionProfile, -): acp.ToolCallUpdate { - const content = permissionProfileContent(permissions); - return { - toolCallId: itemId, - name: "request_permissions", - kind: "other", - status: "pending", - title: "Additional sandbox permissions", - rawInput: {permissions, cwd, environmentId}, - ...locationsField(permissionProfilePaths(permissions)), - ...(content.length > 0 ? {content} : {}), - }; -} - -function commandTitle(actions?: CommandAction[] | null): string { - const first = actions?.[0]; - if (!first) return "Run command"; - switch (first.type) { - case "read": - return actions?.length === 1 ? "Read file" : "Run command with file reads"; - case "listFiles": - return "List files"; - case "search": - return "Search files"; - case "unknown": - return "Run command"; - } -} - -function commandActionPaths(actions?: CommandAction[] | null): string[] { - return unique((actions ?? []).flatMap(action => { - switch (action.type) { - case "read": - return [action.path]; - case "listFiles": - case "search": - return action.path ? [action.path] : []; - case "unknown": - return []; - } - })); -} - -function fileChangePaths(item?: FileChangeItem): string[] { - return unique(item?.changes.map(change => change.path) ?? []); -} - -function permissionProfilePaths(permissions?: RequestPermissionProfile | AdditionalPermissionProfile | null): string[] { - const fileSystem = permissions?.fileSystem; - return unique([ - ...(fileSystem?.read ?? []), - ...(fileSystem?.write ?? []), - ...(fileSystem?.entries ?? []).flatMap(entry => entry.path.type === "path" ? [entry.path.path] : []), - ]); -} - -function permissionProfileContent( - permissions: RequestPermissionProfile | AdditionalPermissionProfile, -): acp.ToolCallContent[] { - const lines: string[] = []; - const networkEnabled = permissions.network?.enabled; - if (networkEnabled !== null && networkEnabled !== undefined) { - lines.push(networkEnabled ? "Enable network access" : "Disable network access"); - } - for (const entry of permissions.fileSystem?.entries ?? []) { - switch (entry.path.type) { - case "glob_pattern": - lines.push(`${entry.access} filesystem pattern ${entry.path.pattern}`); - break; - case "special": - lines.push(`${entry.access} Codex filesystem scope ${JSON.stringify(entry.path.value)}`); - break; - case "path": - break; - } - } - return lines.length > 0 ? [textContent(lines.join("\n"))] : []; -} - -function locationsField(paths: string[]): Pick | object { - return paths.length > 0 ? {locations: paths.map(path => ({path}))} : {}; -} - -function textContent(text: string): acp.ToolCallContent { - return {type: "content", content: {type: "text", text}}; -} - -function unique(values: string[]): string[] { - return [...new Set(values)]; -} diff --git a/src/subagents/CodexSubagentEventRouter.ts b/src/subagents/CodexSubagentEventRouter.ts index 581b8bf3..7372e1b4 100644 --- a/src/subagents/CodexSubagentEventRouter.ts +++ b/src/subagents/CodexSubagentEventRouter.ts @@ -1,13 +1,12 @@ import type {ServerNotification} from "../app-server"; import type {ThreadItem} from "../app-server/v2"; -import {ACPSessionConnection, type UpdateSessionEvent} from "../ACPSessionConnection"; +import type {ACPSessionConnection} from "../ACPSessionConnection"; import {logger} from "../Logger"; -import { - createCollabAgentToolCallCompleteUpdate, - createCollabAgentToolCallUpdate, - createSubAgentActivityUpdate, -} from "../CodexToolCallMapper"; +import {CollabAgentReporter} from "../tool-calls/reporters/CollabAgentReporter"; +import {SubagentActivityReporter} from "../tool-calls/reporters/SubagentActivityReporter"; +import type {ToolFacts} from "../tool-calls/ToolFacts"; import type {SubagentState} from "./AcpSubagents"; +import {PendingNotificationBuffer} from "./PendingNotificationBuffer"; import {isRootAgentPath, nameFromAgentPath, normalizeAgentPath} from "./CodexAgentPath"; type NativeSubagent = { @@ -25,8 +24,7 @@ type PendingSubagent = { parentThreadId: string; parentSessionId: string; task: string; - buffered: ServerNotification[]; - droppedBufferedNotifications: number; + buffered: PendingNotificationBuffer; }; export type ClosingChildSession = { @@ -38,7 +36,6 @@ export type ClosingChildSession = { /** Owns native lifecycle, child routing, waiting, and legacy activity deduplication. */ export class CodexSubagentEventRouter { private static readonly DEFAULT_WAIT_TIMEOUT_MS = 10 * 60 * 1000; - private static readonly MAX_PENDING_NOTIFICATIONS = 256; private readonly children = new Map(); private readonly pendingSpawns = new Map(); @@ -48,10 +45,15 @@ export class CodexSubagentEventRouter { private readonly replayQueue: ServerNotification[] = []; private readonly activeLegacyActivities = new Set(); + /** + * @param onChildSessionEnded runs when a native child session ends, so that the owner can release + * the state that it keeps per child session. + */ constructor( private readonly rootSessionId: string, private readonly supported: boolean, private readonly session: ACPSessionConnection, + private readonly onChildSessionEnded: (sessionId: string) => void = () => {}, ) {} async handle(notification: ServerNotification): Promise { @@ -77,15 +79,7 @@ export class CodexSubagentEventRouter { } const notificationThreadId = (notification.params as {threadId?: unknown}).threadId; if (typeof notificationThreadId === "string" && this.pendingSpawns.has(notificationThreadId)) { - const pending = this.pendingSpawns.get(notificationThreadId)!; - if (pending.buffered.length === CodexSubagentEventRouter.MAX_PENDING_NOTIFICATIONS) { - pending.buffered.shift(); - pending.droppedBufferedNotifications += 1; - if (pending.droppedBufferedNotifications === 1) { - logger.log(`Pending subagent ${notificationThreadId} exceeded the notification buffer; dropping oldest updates`); - } - } - pending.buffered.push(notification); + this.pendingSpawns.get(notificationThreadId)!.buffered.push(notification); return true; } if (notification.method !== "item/started" && notification.method !== "item/completed") { @@ -147,8 +141,7 @@ export class CodexSubagentEventRouter { parentThreadId, parentSessionId, task: item.prompt?.trim() || "Delegated task", - buffered: [], - droppedBufferedNotifications: 0, + buffered: new PendingNotificationBuffer(childSessionId), }); representedSpawn = true; } @@ -228,24 +221,22 @@ export class CodexSubagentEventRouter { }); } - legacyActivityStarted(item: ThreadItem & {type: "subAgentActivity"}): UpdateSessionEvent { + legacyActivityStarted(item: ThreadItem & {type: "subAgentActivity"}): ToolFacts { this.activeLegacyActivities.add(item.id); - return createSubAgentActivityUpdate(item, "in_progress", "tool_call"); + return SubagentActivityReporter.activity(item, "in_progress", "start"); } - legacyCollaborationStarted(item: ThreadItem & {type: "collabAgentToolCall"}): UpdateSessionEvent { - return createCollabAgentToolCallUpdate(item); + legacyCollaborationStarted(item: ThreadItem & {type: "collabAgentToolCall"}): ToolFacts { + return CollabAgentReporter.started(item); } - legacyCollaborationCompleted(item: ThreadItem & {type: "collabAgentToolCall"}): UpdateSessionEvent { - return createCollabAgentToolCallCompleteUpdate(item); + legacyCollaborationCompleted(item: ThreadItem & {type: "collabAgentToolCall"}): ToolFacts { + return CollabAgentReporter.completed(item); } - legacyActivityCompleted(item: ThreadItem & {type: "subAgentActivity"}): UpdateSessionEvent { - const sessionUpdate = this.activeLegacyActivities.delete(item.id) - ? "tool_call_update" - : "tool_call"; - return createSubAgentActivityUpdate(item, "completed", sessionUpdate); + legacyActivityCompleted(item: ThreadItem & {type: "subAgentActivity"}): ToolFacts { + const report = this.activeLegacyActivities.delete(item.id) ? "update" : "start"; + return SubagentActivityReporter.activity(item, "completed", report); } /** The caller finalizes pending child updates before closing timed-out sessions. */ @@ -336,7 +327,7 @@ export class CodexSubagentEventRouter { generation: 1, }); this.pendingSpawns.delete(childSessionId); - this.replayQueue.push(...(pending?.buffered ?? [])); + this.replayQueue.push(...(pending?.buffered.take() ?? [])); this.resolveMaterialization(childSessionId, childSessionId); } @@ -345,6 +336,7 @@ export class CodexSubagentEventRouter { if (!pending) return; this.pendingSpawns.delete(childSessionId); this.terminalPendingSpawns.set(childSessionId, pending); + this.onChildSessionEnded(childSessionId); this.resolveMaterialization(childSessionId, null); this.notifyWaiters(); } @@ -364,6 +356,7 @@ export class CodexSubagentEventRouter { if (child.terminalState === state) delete child.terminalState; throw error; } + this.onChildSessionEnded(child.sessionId); this.notifyWaiters(); } diff --git a/src/subagents/PendingNotificationBuffer.ts b/src/subagents/PendingNotificationBuffer.ts new file mode 100644 index 00000000..ab564c37 --- /dev/null +++ b/src/subagents/PendingNotificationBuffer.ts @@ -0,0 +1,71 @@ +import type {ServerNotification} from "../app-server"; +import {logger} from "../Logger"; + +/** The notification field that carries appended text, per delta method. */ +const DELTA_FIELDS: Partial> = { + "item/agentMessage/delta": "delta", + "item/plan/delta": "delta", + "item/reasoning/summaryTextDelta": "delta", + "item/reasoning/textDelta": "delta", + "item/commandExecution/outputDelta": "delta", + "item/fileChange/outputDelta": "delta", +}; + +/** + * Holds the notifications of a subagent until the adapter can route them to its session. + * + * The buffer keeps every notification. Adjacent text deltas of the same item merge into one notification, + * so a long stream does not grow the count. The buffer is bounded by bytes. + * Only when that hard cap is hit does it drop a notification, and it logs the drop. + */ +export class PendingNotificationBuffer { + static readonly MAX_BYTES = 32 * 1024 * 1024; + + private readonly notifications: ServerNotification[] = []; + private bytes = 0; + private dropped = 0; + + constructor(private readonly threadId: string, private readonly maxBytes = PendingNotificationBuffer.MAX_BYTES) {} + + push(notification: ServerNotification): void { + const deltaField = DELTA_FIELDS[notification.method]; + const last = this.notifications.at(-1); + if (deltaField !== undefined && last !== undefined && sameStream(last, notification, deltaField)) { + const delta = String((notification.params as Record)[deltaField] ?? ""); + if (!this.reserve(Buffer.byteLength(delta, "utf8"))) return; + const params = last.params as Record; + (last as {params: Record}).params = {...params, [deltaField]: `${params[deltaField]}${delta}`}; + return; + } + if (!this.reserve(Buffer.byteLength(JSON.stringify(notification), "utf8"))) return; + // A copy, because a merge replaces the params of the stored notification. + this.notifications.push({...notification}); + } + + take(): ServerNotification[] { + this.bytes = 0; + return this.notifications.splice(0); + } + + get size(): number { + return this.notifications.length; + } + + private reserve(bytes: number): boolean { + if (this.bytes + bytes <= this.maxBytes) { + this.bytes += bytes; + return true; + } + this.dropped += 1; + if (this.dropped === 1) { + logger.log(`Pending subagent ${this.threadId} exceeded the notification buffer of ${this.maxBytes} bytes; dropping updates`); + } + return false; + } +} + +function sameStream(previous: ServerNotification, next: ServerNotification, deltaField: string): boolean { + if (previous.method !== next.method) return false; + return JSON.stringify({...previous.params, [deltaField]: null}) + === JSON.stringify({...next.params, [deltaField]: null}); +} diff --git a/src/tool-calls/AcpToolCallRenderer.ts b/src/tool-calls/AcpToolCallRenderer.ts new file mode 100644 index 00000000..f4741126 --- /dev/null +++ b/src/tool-calls/AcpToolCallRenderer.ts @@ -0,0 +1,188 @@ +import type * as acp from "@agentclientprotocol/sdk"; +import type {UpdateSessionEvent} from "../ACPSessionConnection"; +import {AIR_CONTEXT_COMPACTION_KEY, withAirMeta} from "../AirExtension"; +import type {ClientCapabilities} from "./ClientCapabilities"; +import type {CommandEnd, PermissionToolFacts, StandardToolCallFields, ToolFacts} from "./ToolFacts"; + +export const AIR_SUBAGENT_KEY = "subagent"; + +type ToolCallReport = Extract; + +/** + * Turns the facts of a `ToolReporter` into ACP tool call fields. + * + * Each fact goes into one field, see `docs/air-extensions.md#tool-call-contract`. + * The capabilities decide the terminal channel and the display copy of the input. + * `ToolCallReports` then drops the fields that an earlier report already sent. + */ +export class AcpToolCallRenderer { + constructor(readonly capabilities: ClientCapabilities) {} + + render(facts: ToolFacts): ToolCallReport { + const rendered: Record = { + toolCallId: facts.toolCallId, + ...(facts.name === undefined ? {} : {name: facts.name}), + ...(facts.kind === undefined ? {} : {kind: facts.kind}), + ...(facts.title === undefined ? {} : {title: facts.title}), + ...(facts.status === undefined ? {} : {status: facts.status}), + ...this.contentField(facts), + ...(facts.locations === undefined ? {} : {locations: facts.locations.map(path => ({path}))}), + ...(facts.input === undefined ? {} : {rawInput: facts.input}), + ...(facts.opaqueResult === undefined ? {} : {rawOutput: facts.opaqueResult}), + }; + const meta = this.capabilities.airClient ? this.airMeta(facts) : this.standardMeta(facts); + if (!this.capabilities.airClient) { + applyStandardFields(rendered, facts.standard); + if (facts.standard?.commandEnd !== undefined) { + const rawOutput = this.commandEndRawOutput(facts.standard.commandEnd); + if (rawOutput !== undefined) rendered["rawOutput"] = rawOutput; + } + } + // A `tool_call` requires a title. + if (facts.report === "start" && rendered["title"] === undefined) rendered["title"] = ""; + const fields = { + ...rendered, + ...(Object.keys(meta).length > 0 ? {_meta: meta} : {}), + } as Omit; + if (facts.report === "start") { + return {sessionUpdate: "tool_call", ...fields} as ToolCallReport; + } + return {sessionUpdate: "tool_call_update", ...fields}; + } + + renderPermissionToolCall(facts: PermissionToolFacts): acp.ToolCallUpdate { + const rendered: Record = { + toolCallId: facts.toolCallId, + ...(facts.name === undefined ? {} : {name: facts.name}), + ...(facts.kind === undefined ? {} : {kind: facts.kind}), + ...(facts.status === undefined ? {} : {status: facts.status}), + ...(facts.title === undefined ? {} : {title: facts.title}), + ...(facts.input === undefined ? {} : {rawInput: facts.input}), + ...locationsField(facts.locations), + ...this.contentField(facts), + }; + if (!this.capabilities.airClient) applyStandardFields(rendered, facts.standard); + return rendered as acp.ToolCallUpdate; + } + + private contentField(facts: PermissionToolFacts & {terminal?: unknown}): {content?: acp.ToolCallContent[]} { + const readableInput = facts.readableInput !== undefined && !this.capabilities.air.rawInputRendering + ? [textContent(facts.readableInput)] + : []; + if (facts.terminal === undefined && readableInput.length === 0 && facts.result === undefined) { + return {}; + } + return { + content: [ + ...(facts.terminal === undefined ? [] : [{type: "terminal" as const, terminalId: facts.toolCallId}]), + ...readableInput, + ...(facts.result ?? []), + ], + }; + } + + private airMeta(facts: ToolFacts): Record { + const terminalId = facts.toolCallId; + let meta: Record = { + ...terminalInfo(facts), + ...(facts.terminalInput === undefined ? {} : {terminal_input: {data: facts.terminalInput, terminal_id: terminalId}}), + ...(facts.terminalOutput === undefined ? {} : this.outputChunk(terminalId, facts.terminalOutput, true)), + ...(facts.terminalExit === undefined ? {} : terminalExit(terminalId, facts.terminalExit.exitCode)), + ...mcpMeta(facts), + }; + if (facts.subagent) meta = withAirMeta(meta, AIR_SUBAGENT_KEY, true); + if (facts.contextCompaction !== undefined) { + meta = withAirMeta(meta, AIR_CONTEXT_COMPACTION_KEY, facts.contextCompaction); + } + return meta; + } + + /** + * The metadata of a client that is not AIR. It has no AIR keys. + * The command output comes from `facts.standard`. + */ + private standardMeta(facts: ToolFacts): Record { + const terminalId = facts.toolCallId; + const output = facts.standard?.commandOutput; + const end = facts.standard?.commandEnd; + return { + ...terminalInfo(facts), + ...(output === undefined ? {} : this.outputChunk(terminalId, output.data, output.terminal)), + ...(end === undefined ? {} : this.commandEndMeta(terminalId, end)), + ...(facts.standard?.mcpProgress === undefined ? {} : {mcp_output_delta: {data: facts.standard.mcpProgress}}), + ...mcpMeta(facts), + }; + } + + private outputChunk(terminalId: string, data: string, terminal: boolean): Record { + const key = this.capabilities.terminalOutputKey(terminal); + return key === null ? {} : {[key]: {data, terminal_id: terminalId}}; + } + + private commandEndRawOutput(end: CommandEnd): unknown { + return end.replay || !this.capabilities.terminalOutputDelta + ? {formatted_output: end.output, exit_code: end.exitCode} + : undefined; + } + + /** + * The end of a command for a client that is not AIR. + * The output that did not stream goes to the output channel of a command that shows a terminal. + * A live command without a terminal sends it there only when the client declares `terminal_output_delta`. + * A replayed command without a terminal has only `rawOutput`. + */ + private commandEndMeta(terminalId: string, end: CommandEnd): Record { + const sendOutput = end.output.length > 0 && !end.streamed + && (end.terminal || (!end.replay && this.capabilities.terminalOutputDelta)); + return { + ...(sendOutput ? this.outputChunk(terminalId, end.output, end.terminal) : {}), + ...(end.terminal ? terminalExit(terminalId, end.exitCode) : {}), + }; + } +} + +function terminalInfo(facts: ToolFacts): Record { + return facts.terminal === undefined ? {} : {terminal_info: {cwd: facts.terminal.cwd, terminal_id: facts.toolCallId}}; +} + +function terminalExit(terminalId: string, exitCode: number | null): Record { + return {terminal_exit: {exit_code: exitCode, signal: null, terminal_id: terminalId}}; +} + +function mcpMeta(facts: ToolFacts): Record { + return facts.mcp ? {is_mcp_tool_call: true} : {}; +} + +/** Applies the fields of a client that is not AIR. `null` removes a field. */ +function applyStandardFields( + rendered: Record, + standard: Omit | undefined, +): void { + if (standard === undefined) return; + const locations = standard.locations === undefined || standard.locations === null + ? standard.locations + : locationsField(standard.locations).locations ?? null; + const fields: Array<[string, unknown]> = [ + ["title", standard.title], + ["kind", standard.kind], + ["status", standard.status], + ["locations", locations], + ["content", standard.content], + ["rawInput", standard.rawInput], + ["rawOutput", standard.rawOutput], + ]; + for (const [name, value] of fields) { + if (value === undefined) continue; + if (value === null) delete rendered[name]; + else rendered[name] = value; + } +} + +export function textContent(text: string): acp.ToolCallContent { + return {type: "content", content: {type: "text", text}}; +} + +/** The locations of a permission request. An empty list sends nothing, because the request adds only new facts. */ +function locationsField(paths: string[] | undefined): {locations?: acp.ToolCallLocation[]} { + return paths === undefined || paths.length === 0 ? {} : {locations: paths.map(path => ({path}))}; +} diff --git a/src/tool-calls/ClientCapabilities.ts b/src/tool-calls/ClientCapabilities.ts new file mode 100644 index 00000000..cf9cba88 --- /dev/null +++ b/src/tool-calls/ClientCapabilities.ts @@ -0,0 +1,99 @@ +import type * as acp from "@agentclientprotocol/sdk"; +import {AIR_DIFF_PATCH_KEY, clientSupportsAirCapability, isAirClient} from "../AirExtension"; + +export const AIR_RAW_INPUT_RENDERING_KEY = "rawInputRendering"; +export const AIR_PLAN_CONTENT_DELTA_KEY = "planContentDelta"; + +/** The `_meta` key of a command output chunk. */ +export type TerminalOutputKey = "terminal_output" | "terminal_output_delta"; + +/** The AIR capabilities that change the tool call and plan reports. */ +export type AirCapabilities = { + /** AIR renders `rawInput` itself, so the adapter sends no display copy of the input in `content`. */ + readonly rawInputRendering: boolean; + /** AIR appends `plan_update._meta.jetbrains.air.contentDelta` to the plan content. */ + readonly planContentDelta: boolean; + /** AIR reads a file change as a Git patch, see `docs/air-extensions.md#diff-patch`. */ + readonly diffPatch: boolean; +}; + +type ClientCapabilityValues = { + readonly airClient: boolean; + readonly terminalOutput: boolean; + readonly terminalOutputDelta: boolean; + readonly planUpdates: boolean; + readonly air: AirCapabilities; +}; + +/** + * The client capabilities that decide how the adapter reports tool calls and plans. + * The adapter reads them once in `initialize`. See `docs/air-extensions.md#tool-call-contract`. + * + * Only AIR gets the reports of the tool call contract. + * Every other client gets the reports of the adapter before the contract, see `StandardToolCallFields`. + */ +export class ClientCapabilities { + static readonly DEFAULT = new ClientCapabilities({ + airClient: false, + terminalOutput: false, + terminalOutputDelta: false, + planUpdates: false, + air: {rawInputRendering: false, planContentDelta: false, diffPatch: false}, + }); + + /** The client declares `_meta.jetbrains.air`. */ + readonly airClient: boolean; + /** The client declares `_meta.terminal_output`, the Zed convention for command output chunks. */ + readonly terminalOutput: boolean; + /** The client declares `_meta.terminal_output_delta` and appends the output chunks. */ + readonly terminalOutputDelta: boolean; + /** The client shows `plan_update`. Other clients get the plan as agent message text. */ + readonly planUpdates: boolean; + readonly air: AirCapabilities; + + private constructor(values: ClientCapabilityValues) { + this.airClient = values.airClient; + this.terminalOutput = values.terminalOutput; + this.terminalOutputDelta = values.terminalOutputDelta; + this.planUpdates = values.planUpdates; + this.air = values.air; + } + + static from(capabilities: acp.ClientCapabilities | null | undefined): ClientCapabilities { + return new ClientCapabilities({ + airClient: isAirClient(capabilities), + terminalOutput: capabilities?._meta?.["terminal_output"] === true, + terminalOutputDelta: capabilities?._meta?.["terminal_output_delta"] === true, + planUpdates: capabilities?.plan != null, + air: { + rawInputRendering: clientSupportsAirCapability(capabilities, AIR_RAW_INPUT_RENDERING_KEY), + planContentDelta: clientSupportsAirCapability(capabilities, AIR_PLAN_CONTENT_DELTA_KEY), + diffPatch: clientSupportsAirCapability(capabilities, AIR_DIFF_PATCH_KEY), + }, + }); + } + + /** + * The key of the output chunks of a command, or `null` when the client gets no chunks. + * A client that declares `terminal_output_delta` gets appends for every command. + * A client that declares `terminal_output` (Zed) gets `terminal_output` for a command that shows a terminal. + * Every other client that is not AIR gets `terminal_output_delta`, as before the tool call contract. + * AIR without either capability gets no chunks. + */ + terminalOutputKey(terminal: boolean): TerminalOutputKey | null { + if (this.terminalOutputDelta) return "terminal_output_delta"; + if (this.terminalOutput && terminal) return "terminal_output"; + return this.airClient ? null : "terminal_output_delta"; + } + + with(changes: Partial> & {air?: Partial}): ClientCapabilities { + return new ClientCapabilities({ + airClient: changes.airClient ?? this.airClient, + terminalOutput: changes.terminalOutput ?? this.terminalOutput, + terminalOutputDelta: changes.terminalOutputDelta ?? this.terminalOutputDelta, + planUpdates: changes.planUpdates ?? this.planUpdates, + air: {...this.air, ...changes.air}, + }); + } +} + diff --git a/src/tool-calls/ToolFacts.ts b/src/tool-calls/ToolFacts.ts new file mode 100644 index 00000000..e82857b8 --- /dev/null +++ b/src/tool-calls/ToolFacts.ts @@ -0,0 +1,96 @@ +import type * as acp from "@agentclientprotocol/sdk"; +import type {ContextCompactionMetadata} from "../ContextCompactionMeta"; + +/** + * What a `ToolReporter` knows about one report of a tool call, before the client shape is chosen. + * + * Each fact has one field. `AcpToolCallRenderer` puts each fact into exactly one ACP field, + * see `docs/air-extensions.md#tool-call-contract`. An absent field means "no news" for this report. + */ +export type ToolFacts = { + toolCallId: string; + /** `start` renders a `tool_call`, `update` renders a `tool_call_update`. */ + report: "start" | "update"; + /** The programmatic tool name. */ + name?: string; + kind?: acp.ToolKind; + title?: string; + status?: acp.ToolCallStatus; + /** + * Paths of the files that the tool reads, searches or edits. + * In a report, an empty list clears the locations. A permission request omits an empty list. + */ + locations?: string[]; + /** The tool parameters. */ + input?: Record; + /** + * Input that the user reads, for example a subagent prompt or a question. + * A client without the AIR `rawInputRendering` capability gets one display copy in `content`. + */ + readableInput?: string; + /** The result to show. */ + result?: acp.ToolCallContent[]; + /** A result that has no display form. */ + opaqueResult?: unknown; + /** The tool call shows a terminal. The terminal id is the tool call id. Only a `start` report sets it. */ + terminal?: {cwd: string}; + /** Command output to append to the terminal. */ + terminalOutput?: string; + /** Text that was written to the stdin of the command. */ + terminalInput?: string; + /** The command ended. */ + terminalExit?: {exitCode: number | null}; + mcp?: boolean; + subagent?: boolean; + contextCompaction?: ContextCompactionMetadata; + /** The fields of a client that is not AIR, where they differ from the fields above. */ + standard?: StandardToolCallFields; +}; + +/** + * The report fields of a client without `_meta.jetbrains.air`, where they differ from the contract fields. + * + * Only AIR gets the fields of `docs/air-extensions.md#tool-call-contract`. + * Every other client keeps the fields that the adapter sent before that contract, so Zed and other ACP clients + * see no change. A present field replaces the rendered field, and `null` removes it. + * `AcpToolCallRenderer` applies these fields and never renders an AIR metadata key for such a client. + */ +export type StandardToolCallFields = { + title?: string; + kind?: acp.ToolKind; + status?: acp.ToolCallStatus; + locations?: string[] | null; + content?: acp.ToolCallContent[] | null; + rawInput?: unknown; + rawOutput?: unknown; + /** + * A chunk of command output, or the text written to the command stdin. + * It goes to the output channel that the client declares, see `ClientCapabilities.terminalOutputKey`. + */ + commandOutput?: {data: string; terminal: boolean}; + /** The end of a command: the output in `rawOutput.formatted_output`, the terminal output, and the exit. */ + commandEnd?: CommandEnd; + /** MCP progress text to append in `_meta.mcp_output_delta`. AIR gets no MCP progress. */ + mcpProgress?: string; +}; + +export type CommandEnd = { + /** The whole output of the command. */ + output: string; + exitCode: number | null; + /** The command shows a terminal. */ + terminal: boolean; + /** Output or stdin chunks of the command came before the end. */ + streamed: boolean; + /** The end comes from the thread history. */ + replay: boolean; +}; + +/** + * The tool call of a permission request. The client merges it into the stored tool call. + * A reporter sets only `toolCallId`, `title`, `input`, and the facts that the client does not have yet. + */ +export type PermissionToolFacts = Omit & { + standard?: Omit; +}; diff --git a/src/tool-calls/reporters/CollabAgentReporter.ts b/src/tool-calls/reporters/CollabAgentReporter.ts new file mode 100644 index 00000000..0ec31ff4 --- /dev/null +++ b/src/tool-calls/reporters/CollabAgentReporter.ts @@ -0,0 +1,51 @@ +import type {ThreadItem} from "../../app-server/v2"; +import type {ToolFacts} from "../ToolFacts"; +import {toToolStatus} from "./ToolStatus"; + +type CollabAgentToolCallItem = ThreadItem & {type: "collabAgentToolCall"}; + +/** + * Reports a Codex collaboration tool call, for a client without native subagent sessions. + * The prompt is input that the user reads. + * `rawInput` keeps `senderThreadId`, `receiverThreadIds`, and `agentsStates`, because AIR recognizes + * a collaboration tool call by these three keys. + * Only a spawn is a subagent. A wait, a message, a resume, or a close controls an existing subagent. + * A client that is not AIR also gets the Codex `status` in `rawInput`. + */ +export class CollabAgentReporter { + static started(item: CollabAgentToolCallItem): ToolFacts { + return { + ...facts(item, "start"), + kind: "other", + ...(item.prompt ? {readableInput: item.prompt} : {}), + }; + } + + static completed(item: CollabAgentToolCallItem): ToolFacts { + return facts(item, "update"); + } +} + +function facts(item: CollabAgentToolCallItem, report: ToolFacts["report"]): ToolFacts { + const input = { + prompt: item.prompt, + senderThreadId: item.senderThreadId, + receiverThreadIds: item.receiverThreadIds, + agentsStates: item.agentsStates, + model: item.model, + reasoningEffort: item.reasoningEffort, + }; + return { + toolCallId: item.id, + report, + title: item.tool, + status: toToolStatus(item.status), + input, + ...(item.tool === "spawnAgent" ? {subagent: true} : {}), + standard: { + content: null, + rawInput: {...input, status: item.status}, + rawOutput: null, + }, + }; +} diff --git a/src/tool-calls/reporters/CommandReporter.ts b/src/tool-calls/reporters/CommandReporter.ts new file mode 100644 index 00000000..a877e1a9 --- /dev/null +++ b/src/tool-calls/reporters/CommandReporter.ts @@ -0,0 +1,285 @@ +import type { + AdditionalPermissionProfile, + CommandAction, + CommandExecutionRequestApprovalParams, + CommandExecutionStatus, + ThreadItem, +} from "../../app-server/v2"; +import {stripShellPrefix} from "../../CommandUtils"; +import {commandToolName} from "../../ToolCallName"; +import {textContent} from "../AcpToolCallRenderer"; +import type {PermissionToolFacts, ToolFacts} from "../ToolFacts"; +import {permissionProfileContent, permissionProfilePaths} from "./SandboxPermissionReporter"; +import {toToolStatus} from "./ToolStatus"; + +export type CommandPermissionParams = CommandExecutionRequestApprovalParams & { + additionalPermissions?: AdditionalPermissionProfile | null; +}; + +type CommandItem = ThreadItem & {type: "commandExecution"}; + +/** The largest output that the reporter collects for a command whose output does not stream. */ +const MAX_COLLECTED_OUTPUT = 1024 * 1024; + +/** + * Reports a Codex command execution. + * + * A shell command shows a terminal: its output streams into the terminal channel, and its end sends the exit code. + * A read, search or list command has no terminal. The reporter collects its output and sends it once as the result. + */ +export class CommandReporter { + /** Read, search and list commands of the live turn. */ + private readonly nonTerminalCommands = new Set(); + /** Started commands that show a terminal. */ + private readonly terminalCommands = new Set(); + /** Terminal commands that already streamed output. */ + private readonly streamedCommands = new Set(); + /** Commands that already sent output or stdin chunks to a client that is not AIR. */ + private readonly standardStreamedCommands = new Set(); + private readonly collectedOutput = new Map(); + + started(item: CommandItem): ToolFacts { + if (usesTerminal(item)) { + this.nonTerminalCommands.delete(item.id); + this.terminalCommands.add(item.id); + } else { + this.nonTerminalCommands.add(item.id); + this.terminalCommands.delete(item.id); + } + this.streamedCommands.delete(item.id); + this.standardStreamedCommands.delete(item.id); + this.collectedOutput.delete(item.id); + return startFacts(item); + } + + /** + * A chunk of command output. + * For AIR, the output of a read, search or list command goes to the completion instead. + */ + outputDelta(itemId: string, delta: string): ToolFacts { + if (delta.length > 0) this.standardStreamedCommands.add(itemId); + const standard = {commandOutput: {data: delta, terminal: this.terminalCommands.has(itemId)}}; + if (this.nonTerminalCommands.has(itemId)) { + const collected = (this.collectedOutput.get(itemId) ?? "") + delta; + this.collectedOutput.set(itemId, collected.length > MAX_COLLECTED_OUTPUT + ? collected.slice(collected.length - MAX_COLLECTED_OUTPUT) + : collected); + return {toolCallId: itemId, report: "update", standard}; + } + if (delta.length > 0) this.streamedCommands.add(itemId); + return {toolCallId: itemId, report: "update", terminalOutput: delta, standard}; + } + + /** + * Text that was written to the stdin of a running command. For AIR, it is not command output. + * A client that is not AIR gets it as an output chunk on its own line. + */ + terminalInput(itemId: string, stdin: string): ToolFacts { + this.standardStreamedCommands.add(itemId); + const standard = {commandOutput: {data: `\n${stdin}\n`, terminal: this.terminalCommands.has(itemId)}}; + if (this.nonTerminalCommands.has(itemId)) return {toolCallId: itemId, report: "update", standard}; + return {toolCallId: itemId, report: "update", terminalInput: stdin, standard}; + } + + /** Pass `withName` when the completion can be the first report of the tool call. */ + completed(item: CommandItem, withName = false): ToolFacts { + const collected = this.collectedOutput.get(item.id); + this.collectedOutput.delete(item.id); + this.nonTerminalCommands.delete(item.id); + const streamed = this.streamedCommands.delete(item.id); + const facts = completionFacts(item, streamed, collected, withName); + return { + ...facts, + standard: { + content: null, + commandEnd: { + output: item.aggregatedOutput ?? "", + exitCode: item.exitCode, + terminal: this.terminalCommands.delete(item.id), + streamed: this.standardStreamedCommands.delete(item.id), + replay: false, + }, + }, + }; + } + + /** + * The tool call of an approval request. It carries the title and the parameters. + * A started command keeps its title, kind, status and locations, so the request repeats none of them. + * A network request and additional permissions add their own title, locations and text. + */ + static permission(params: CommandPermissionParams, started: boolean, name?: string): PermissionToolFacts { + const network = params.networkApprovalContext; + const networkUrl = network?.protocol === "http" || network?.protocol === "https" + ? `${network.protocol}://${network.host}` + : undefined; + const input = { + ...(params.command ? {command: stripShellPrefix(params.command)} : {}), + ...(params.cwd ? {cwd: params.cwd} : {}), + ...(networkUrl ? {url: networkUrl} : {}), + ...(params.additionalPermissions ? {additionalPermissions: params.additionalPermissions} : {}), + }; + const actions = params.commandActions ?? []; + const permissionContent = params.additionalPermissions + ? permissionProfileContent(params.additionalPermissions) + : []; + const content = [ + ...(network ? [textContent(`${network.protocol} access to ${network.host}`)] : []), + ...permissionContent, + ]; + return { + toolCallId: params.itemId, + ...(name === undefined ? {} : {name}), + ...(started ? {} : {kind: "execute" as const, status: "pending" as const}), + title: network + ? `${network.protocol} network access to ${network.host}` + : started ? startedCommandTitle(params, actions) : permissionTitle(actions), + ...(Object.keys(input).length > 0 ? {input} : {}), + locations: [...new Set([ + ...(started ? [] : actionPaths(actions)), + ...permissionProfilePaths(params.additionalPermissions), + ])], + ...(content.length > 0 ? {result: content} : {}), + standard: { + kind: "execute", + status: "pending", + title: network ? `${network.protocol} network access to ${network.host}` : permissionTitle(actions), + locations: [...new Set([ + ...actionPaths(actions), + ...permissionProfilePaths(params.additionalPermissions), + ])], + }, + }; + } + + /** The reports of a command from the thread history. */ + static history(item: CommandItem): ToolFacts[] { + const start = startFacts(item); + if (item.status === "inProgress") return [start]; + return [start, { + ...completionFacts(item, false, undefined, false), + standard: { + content: null, + commandEnd: { + output: item.aggregatedOutput ?? "", + exitCode: item.exitCode, + terminal: usesTerminal(item), + streamed: false, + replay: true, + }, + }, + }]; + } +} + +export function usesTerminal(item: CommandItem): boolean { + const action = singleAction(item.commandActions); + return action === undefined || action.type === "unknown"; +} + +function startFacts(item: CommandItem): ToolFacts { + const name = commandToolName(item.source); + const action = singleAction(item.commandActions); + return { + ...commandActionFacts(item.id, item.status, item.cwd, action ?? {type: "unknown", command: item.command}), + ...(name === undefined ? {} : {name}), + }; +} + +/** The start report of a command with one parsed action. The history fallback also uses it. */ +export function commandActionFacts( + id: string, + status: CommandExecutionStatus, + cwd: string, + action: CommandAction, +): ToolFacts { + const common = {toolCallId: id, report: "start" as const, status: toToolStatus(status)}; + switch (action.type) { + case "read": + return {...common, kind: "read", title: `Read file '${action.path}'`, locations: [action.path]}; + case "search": + return {...common, kind: "search", title: searchTitle(action.query, action.path)}; + case "listFiles": + return {...common, kind: "read", title: action.path ? `List files in '${action.path}'` : "List files"}; + case "unknown": + return { + ...common, + kind: "execute", + title: stripShellPrefix(action.command), + input: {command: action.command, cwd}, + terminal: {cwd}, + }; + } +} + +function completionFacts( + item: CommandItem, + streamed: boolean, + collected: string | undefined, + withName: boolean, +): ToolFacts { + const name = withName ? commandToolName(item.source) : undefined; + const output = item.aggregatedOutput ?? collected ?? ""; + const facts: ToolFacts = { + toolCallId: item.id, + report: "update", + ...(name === undefined ? {} : {name}), + status: item.status === "completed" ? "completed" : "failed", + }; + if (!usesTerminal(item)) { + return output.length > 0 ? {...facts, result: [textContent(output)]} : facts; + } + return { + ...facts, + ...(!streamed && output.length > 0 ? {terminalOutput: output} : {}), + terminalExit: {exitCode: item.exitCode}, + }; +} + +function singleAction(actions: CommandAction[]): CommandAction | undefined { + return actions.length === 1 ? actions[0] : undefined; +} + +export function searchTitle(query: string | null, path: string | null): string { + if (query && path) return `Search for '${query}' in ${path}`; + if (query) return `Search for '${query}'`; + if (path) return `Search in '${path}'`; + return "Search"; +} + +/** The title that the started tool call already shows. */ +function startedCommandTitle(params: CommandPermissionParams, actions: CommandAction[]): string { + const action = singleAction(actions) ?? (params.command ? {type: "unknown" as const, command: params.command} : undefined); + return action === undefined + ? "Run command" + : commandActionFacts(params.itemId, "inProgress", params.cwd ?? "", action).title ?? "Run command"; +} + +function permissionTitle(actions: CommandAction[]): string { + const first = actions[0]; + if (!first) return "Run command"; + switch (first.type) { + case "read": + return actions.length === 1 ? "Read file" : "Run command with file reads"; + case "listFiles": + return "List files"; + case "search": + return "Search files"; + case "unknown": + return "Run command"; + } +} + +function actionPaths(actions: CommandAction[]): string[] { + return actions.flatMap(action => { + switch (action.type) { + case "read": + return [action.path]; + case "listFiles": + case "search": + return action.path ? [action.path] : []; + case "unknown": + return []; + } + }); +} diff --git a/src/tool-calls/reporters/CompactionReporter.ts b/src/tool-calls/reporters/CompactionReporter.ts new file mode 100644 index 00000000..7d1c7af6 --- /dev/null +++ b/src/tool-calls/reporters/CompactionReporter.ts @@ -0,0 +1,26 @@ +import type {ThreadItem} from "../../app-server/v2"; +import {createContextCompactionMetadata} from "../../ContextCompactionMeta"; +import type {ToolFacts} from "../ToolFacts"; + +type ContextCompactionItem = ThreadItem & {type: "contextCompaction"}; + +const TITLE = "Compact conversation"; + +/** Reports a Codex context compaction as a tool call, for a client without ACP compaction updates. */ +export class CompactionReporter { + static started(item: ContextCompactionItem): ToolFacts { + return {...facts(item, "start"), kind: "think", status: "in_progress"}; + } + + static completed(item: ContextCompactionItem): ToolFacts { + return {...facts(item, "update"), status: "completed"}; + } + + static history(item: ContextCompactionItem): ToolFacts { + return {...facts(item, "start"), kind: "think", status: "completed"}; + } +} + +function facts(item: ContextCompactionItem, report: ToolFacts["report"]): ToolFacts { + return {toolCallId: item.id, report, title: TITLE, contextCompaction: createContextCompactionMetadata()}; +} diff --git a/src/tool-calls/reporters/DynamicToolReporter.ts b/src/tool-calls/reporters/DynamicToolReporter.ts new file mode 100644 index 00000000..d895b4d3 --- /dev/null +++ b/src/tool-calls/reporters/DynamicToolReporter.ts @@ -0,0 +1,49 @@ +import type * as acp from "@agentclientprotocol/sdk"; +import type {DynamicToolCallOutputContentItem, ThreadItem} from "../../app-server/v2"; +import {functionToolName} from "../../ToolCallName"; +import type {ToolFacts} from "../ToolFacts"; +import {toToolStatus} from "./ToolStatus"; + +type DynamicToolCallItem = ThreadItem & {type: "dynamicToolCall"}; + +/** Reports a client-defined dynamic tool call. Its content items are the result. The success flag is the status. */ +export class DynamicToolReporter { + static started(item: DynamicToolCallItem): ToolFacts { + return { + toolCallId: item.id, + report: "start", + name: functionToolName(item.tool, item.namespace), + kind: "execute", + title: item.tool, + status: toToolStatus(item.status), + input: {arguments: item.arguments}, + ...resultFacts(item), + }; + } + + static completed(item: DynamicToolCallItem): ToolFacts { + return { + toolCallId: item.id, + report: "update", + name: functionToolName(item.tool, item.namespace), + status: item.status === "completed" ? "completed" : "failed", + ...resultFacts(item), + }; + } +} + +function resultFacts(item: DynamicToolCallItem): Pick { + if (item.contentItems === null) return {}; + return {result: item.contentItems.map(contentItem => ({type: "content", content: displayBlock(contentItem)}))}; +} + +function displayBlock(item: DynamicToolCallOutputContentItem): acp.ContentBlock { + switch (item.type) { + case "inputText": + return {type: "text", text: item.text}; + case "inputImage": + return {type: "resource_link", uri: item.imageUrl, name: "image"}; + case "inputAudio": + return {type: "resource_link", uri: item.audioUrl, name: "audio"}; + } +} diff --git a/src/tool-calls/reporters/ElicitationReporter.ts b/src/tool-calls/reporters/ElicitationReporter.ts new file mode 100644 index 00000000..b86799c7 --- /dev/null +++ b/src/tool-calls/reporters/ElicitationReporter.ts @@ -0,0 +1,53 @@ +import type {McpServerElicitationRequestParams} from "../../app-server/v2"; +import type {PermissionToolFacts, ToolFacts} from "../ToolFacts"; + +/** + * Reports an MCP elicitation that the adapter presents as a permission request. + * + * A tool approval that belongs to a started MCP tool call reuses that tool call. + * Any other elicitation is a new tool call. Its message is a question that the user reads. + */ +export class ElicitationReporter { + static permission( + params: McpServerElicitationRequestParams, + isToolApproval: boolean, + correlatedCallId: string | undefined, + nextStandaloneToolCallId: () => string, + ): PermissionToolFacts { + if (params.mode === "form" || params.mode === "openai/form") { + if (correlatedCallId !== undefined) { + // The client already shows the MCP tool call. Only its status changes. + return {toolCallId: correlatedCallId, status: "pending"}; + } + return { + toolCallId: nextStandaloneToolCallId(), + kind: isToolApproval ? "execute" : "other", + status: "pending", + title: isToolApproval ? "MCP tool call approval" : "Question from MCP server", + input: {serverName: params.serverName, description: params.message, schema: params.requestedSchema}, + readableInput: params.message, + }; + } + if (params.mode !== "url") { + throw new Error(`Unsupported MCP elicitation mode: ${params.mode}`); + } + return { + toolCallId: `elicitation-${params.elicitationId}`, + kind: "fetch", + status: "pending", + title: "MCP server requests to open a URL", + input: {serverName: params.serverName, description: params.message, url: params.url}, + readableInput: params.message, + }; + } + + /** The user accepted a tool approval, so the MCP tool call runs. */ + static accepted(correlatedCallId: string): ToolFacts { + return {toolCallId: correlatedCallId, report: "update", status: "in_progress"}; + } + + /** The user answered a standalone elicitation. */ + static answered(toolCallId: string, action: string): ToolFacts { + return {toolCallId, report: "update", status: "completed", opaqueResult: {action}}; + } +} diff --git a/src/tool-calls/reporters/FileChangeReporter.ts b/src/tool-calls/reporters/FileChangeReporter.ts new file mode 100644 index 00000000..99c34ecd --- /dev/null +++ b/src/tool-calls/reporters/FileChangeReporter.ts @@ -0,0 +1,203 @@ +import type {ToolCallContent} from "@agentclientprotocol/sdk"; +import {applyPatch, parsePatch, reversePatch, type StructuredPatch} from "diff"; +import {readFile} from "node:fs/promises"; +import {AIR_DIFF_PATCH_KEY, withAirMeta} from "../../AirExtension"; +import type {FileChangeRequestApprovalParams, FileUpdateChange, ThreadItem} from "../../app-server/v2"; +import {createAddedFileGitPatch, createDeletedFileGitPatch, createUpdateGitPatch} from "../../GitPatch"; +import {logger} from "../../Logger"; +import type {PermissionToolFacts, ToolFacts} from "../ToolFacts"; +import {toToolStatus} from "./ToolStatus"; + +type FileChangeItem = ThreadItem & {type: "fileChange"}; + +export const FILE_CHANGE_TITLE = "Editing files"; + +/** + * Reports a Codex file change. The diff in `content` carries the file text. + * With the AIR `diffPatch` capability, the diff is a Git patch, see `docs/air-extensions.md#diff-patch`. + */ +export class FileChangeReporter { + static async started(item: FileChangeItem, diffPatch: boolean): Promise { + const diffs: ToolCallContent[] = []; + for (const change of item.changes) { + // An unparseable change has no diff. + const content = await createPatchContent(change, diffPatch); + if (content) diffs.push(content); + } + return { + toolCallId: item.id, + report: "start", + title: FILE_CHANGE_TITLE, + kind: "edit", + status: toToolStatus(item.status), + result: diffs, + }; + } + + static completed(item: FileChangeItem): ToolFacts { + return { + toolCallId: item.id, + report: "update", + status: item.status === "completed" ? "completed" : "failed", + }; + } + + /** + * The tool call of an approval request. A started file change already shows its diff, + * so the request adds only the paths. + */ + static permission(params: FileChangeRequestApprovalParams, item: FileChangeItem | undefined): PermissionToolFacts { + return { + toolCallId: params.itemId, + title: FILE_CHANGE_TITLE, + ...(item === undefined ? {kind: "edit", status: "pending"} : {}), + locations: [...new Set(item?.changes.map(change => change.path) ?? [])], + standard: {kind: "edit", status: "pending", title: "Edit files"}, + }; + } +} + +async function createPatchContent( + change: FileUpdateChange, + supportsDiffPatch: boolean, +): Promise { + try { + switch (change.kind.type) { + case "add": + return createAddFileContent(change, supportsDiffPatch); + case "delete": + return createDeleteFileContent(change, supportsDiffPatch); + case "update": + return await createUpdateFileContent(change, change.kind.move_path, supportsDiffPatch); + } + } catch (error) { + logger.log(`Error processing file update change: ${error}`); + return null; + } +} + +function createAddFileContent( + change: FileUpdateChange, + supportsDiffPatch: boolean, +): ToolCallContent { + // app-server always returns file content instead of diff + const patch = supportsDiffPatch ? createAddedFileGitPatch(change.path, change.diff) : null; + if (patch !== null) { + return createPatchOnlyContent(change.path, "add", patch); + } + return { + type: "diff", + oldText: null, + newText: change.diff, + path: change.path, + _meta: { kind: "add" }, + }; +} + +async function createUpdateFileContent( + change: FileUpdateChange, + movePath: string | null, + supportsDiffPatch: boolean, +): Promise { + const unifiedDiff = recoverCorruptedDiff(change.diff); + const targetPath = movePath ?? change.path; + + const gitPatch = supportsDiffPatch ? createUpdateGitPatch(change.path, targetPath, unifiedDiff) : null; + if (gitPatch !== null) { + return createPatchOnlyContent(targetPath, "update", gitPatch); + } + + // The standard diff needs the file text, so it reads the file and applies the Codex hunks. + const patch = parseSinglePatch(unifiedDiff); + if (patch === null) { + logger.log("Skipped a file change whose diff has no single valid patch", {path: change.path}); + return null; + } + + const oldContent = await readFileContent(change.path); + if (oldContent !== null) { + const patchedContent = applyPatch(oldContent, patch); + if (patchedContent === false) { + // If Codex runs in full access mode, the file might already be patched. + // we can verify this by checking if the reverted patch applies. + const revertedContent = applyPatch(oldContent, reversePatch(patch)); + if (revertedContent !== false) { + return createUpdateDiffContent(targetPath, revertedContent, oldContent); + } + return null; + } + return createUpdateDiffContent(targetPath, oldContent, patchedContent); + } + + if (!movePath) return null; + const newContent = await readFileContent(movePath); + if (newContent === null) return null; + + const revertedContent = applyPatch(newContent, reversePatch(patch)); + if (revertedContent === false) return null; + + return createUpdateDiffContent(movePath, revertedContent, newContent); +} + +function parseSinglePatch(diff: string): StructuredPatch | null { + try { + const patches = parsePatch(diff); + return patches.length === 1 ? patches[0]! : null; + } catch { + return null; + } +} + +function createUpdateDiffContent(path: string, oldText: string, newText: string): ToolCallContent { + return { + type: "diff", + oldText, + newText, + path, + _meta: { kind: "update" }, + }; +} + +function createDeleteFileContent( + change: FileUpdateChange, + supportsDiffPatch: boolean, +): ToolCallContent { + // app-server always returns file content instead of diff + const patch = supportsDiffPatch ? createDeletedFileGitPatch(change.path, change.diff) : null; + if (patch !== null) { + return createPatchOnlyContent(change.path, "delete", patch); + } + return { + type: "diff", + oldText: change.diff, + newText: "", + path: change.path, + _meta: { kind: "delete" }, + }; +} + +function createPatchOnlyContent(path: string, kind: string, patch: string): ToolCallContent { + return { + type: "diff", + oldText: null, + newText: "", + path, + _meta: withAirMeta({ kind }, AIR_DIFF_PATCH_KEY, { + version: 1, + format: "git_patch", + text: patch, + }), + }; +} + +async function readFileContent(filePath: string): Promise { + return await readFile(filePath, { encoding: "utf8" }).catch(() => null); +} + +/** + * Fix unified diff content corrupted by codex agent. + * Removes synthetic "Moved to" from the end. + */ +function recoverCorruptedDiff(diff: string): string { + return diff.replace(/\n\nMoved to: .*$/, ""); +} diff --git a/src/tool-calls/reporters/FuzzySearchReporter.ts b/src/tool-calls/reporters/FuzzySearchReporter.ts new file mode 100644 index 00000000..635f9f4b --- /dev/null +++ b/src/tool-calls/reporters/FuzzySearchReporter.ts @@ -0,0 +1,36 @@ +import path from "node:path"; +import type { + FuzzyFileSearchSessionCompletedNotification, + FuzzyFileSearchSessionUpdatedNotification, +} from "../../app-server"; +import type {ToolFacts} from "../ToolFacts"; +import {searchTitle} from "./CommandReporter"; + +/** Reports a Codex fuzzy file search session. The found files are the locations. */ +export class FuzzySearchReporter { + private readonly activeSessions = new Set(); + + updated(event: FuzzyFileSearchSessionUpdatedNotification): ToolFacts { + const toolCallId = fuzzyFileSearchToolCallId(event.sessionId); + const started = !this.activeSessions.has(toolCallId); + this.activeSessions.add(toolCallId); + const facts: ToolFacts = { + toolCallId, + 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)), + }; + return started ? {...facts, kind: "search", input: {query: event.query}} : facts; + } + + completed(event: FuzzyFileSearchSessionCompletedNotification): ToolFacts { + const toolCallId = fuzzyFileSearchToolCallId(event.sessionId); + this.activeSessions.delete(toolCallId); + return {toolCallId, report: "update", status: "completed"}; + } +} + +export function fuzzyFileSearchToolCallId(sessionId: string): string { + return `fuzzyFileSearch.${sessionId}`; +} diff --git a/src/tool-calls/reporters/GuardianReporter.ts b/src/tool-calls/reporters/GuardianReporter.ts new file mode 100644 index 00000000..91302fec --- /dev/null +++ b/src/tool-calls/reporters/GuardianReporter.ts @@ -0,0 +1,152 @@ +import type * as acp from "@agentclientprotocol/sdk"; +import type { + GuardianApprovalReview, + GuardianApprovalReviewAction, + GuardianApprovalReviewStatus, + GuardianCommandSource, + ItemGuardianApprovalReviewCompletedNotification, + ItemGuardianApprovalReviewStartedNotification, +} from "../../app-server/v2"; +import {textContent} from "../AcpToolCallRenderer"; +import type {ToolFacts} from "../ToolFacts"; + +type GuardianApprovalReviewNotification = + | ItemGuardianApprovalReviewStartedNotification + | ItemGuardianApprovalReviewCompletedNotification; + +/** + * Reports a Codex guardian approval review. + * The reviewed action is the input. A client without the AIR `rawInputRendering` capability also reads it as text. + * The review verdict is the result. + */ +export class GuardianReporter { + private readonly activeReviews = new Set(); + + started(event: ItemGuardianApprovalReviewStartedNotification): ToolFacts { + if (this.activeReviews.has(event.reviewId)) return reviewFacts(event, "update"); + this.activeReviews.add(event.reviewId); + return reviewFacts(event, "start"); + } + + completed(event: ItemGuardianApprovalReviewCompletedNotification): ToolFacts { + return reviewFacts(event, this.activeReviews.delete(event.reviewId) ? "update" : "start"); + } +} + +export function guardianApprovalReviewToolCallId(reviewId: string): string { + return `guardian_assessment:${reviewId}`; +} + +function reviewFacts(event: GuardianApprovalReviewNotification, report: ToolFacts["report"]): ToolFacts { + const action = createGuardianApprovalReviewActionSummary(event.action); + return { + toolCallId: guardianApprovalReviewToolCallId(event.reviewId), + report, + ...(report === "start" ? {kind: "think" as const, title: "Guardian Review"} : {}), + status: toAcpGuardianApprovalReviewStatus(event.review.status), + input: {action: event.action}, + ...(action ? {readableInput: `Action: ${action}`} : {}), + result: [reviewVerdict(event.review)], + standard: { + // A client that is not AIR gets one text with the action, and the whole event. + content: [standardReviewText(event.review, action)], + ...(report === "start" ? {rawInput: event} : {rawInput: null, rawOutput: event}), + }, + }; +} + +function standardReviewText(review: GuardianApprovalReview, action: string | null): acp.ToolCallContent { + const lines = [`Status: ${formatGuardianApprovalReviewStatus(review.status)}`]; + if (action) lines.push(`Action: ${action}`); + if (review.riskLevel) lines.push(`Risk: ${review.riskLevel}`); + if (review.userAuthorization) lines.push(`Authorization: ${review.userAuthorization}`); + if (review.rationale?.trim()) lines.push(`Rationale: ${review.rationale}`); + return textContent(lines.join("\n")); +} + +function reviewVerdict(review: GuardianApprovalReview): acp.ToolCallContent { + const lines = [`Status: ${formatGuardianApprovalReviewStatus(review.status)}`]; + if (review.riskLevel) lines.push(`Risk: ${review.riskLevel}`); + if (review.userAuthorization) lines.push(`Authorization: ${review.userAuthorization}`); + if (review.rationale?.trim()) lines.push(`Rationale: ${review.rationale}`); + return textContent(lines.join("\n")); +} + +function toAcpGuardianApprovalReviewStatus(status: GuardianApprovalReviewStatus): acp.ToolCallStatus { + switch (status) { + case "inProgress": + return "in_progress"; + case "approved": + return "completed"; + case "denied": + case "aborted": + case "timedOut": + return "failed"; + } +} + +function formatGuardianApprovalReviewStatus(status: GuardianApprovalReviewStatus): string { + switch (status) { + case "inProgress": + return "In progress"; + case "approved": + return "Approved"; + case "denied": + return "Denied"; + case "aborted": + return "Aborted"; + case "timedOut": + return "Timed out"; + } +} + +function createGuardianApprovalReviewActionSummary(action: GuardianApprovalReviewAction): string | null { + switch (action.type) { + case "command": + return `${guardianCommandSourceLabel(action.source)} ${action.command}`; + case "execve": { + const command = action.argv.length > 0 ? action.argv : [action.program]; + return `${guardianCommandSourceLabel(action.source)} ${shellJoin(command)}`; + } + case "writeStdin": + return `write stdin to process ${action.processId}`; + case "applyPatch": + if (action.files.length === 1) { + return `apply_patch touching ${action.files[0]}`; + } + return `apply_patch touching ${action.files.length} files`; + case "networkAccess": { + const label = action.target.length > 0 ? action.target : action.host; + return `network access to ${label}`; + } + case "mcpToolCall": { + const label = action.connectorName ?? action.server; + return `MCP ${action.toolName} on ${label}`; + } + case "requestPermissions": + return action.reason ?? "request additional permissions"; + } +} + +function guardianCommandSourceLabel(source: GuardianCommandSource): string { + switch (source) { + case "shell": + return "shell"; + case "unifiedExec": + return "exec"; + } +} + +function shellJoin(args: string[]): string { + return args.map(shellQuote).join(" "); +} + +function shellQuote(arg: string): string { + if (arg.length === 0) { + return "''"; + } + if (/^[A-Za-z0-9_/:=+.,@%-]+$/.test(arg)) { + return arg; + } + return `'${arg.replace(/'/g, `'\\''`)}'`; +} diff --git a/src/tool-calls/reporters/ImageGenerationReporter.ts b/src/tool-calls/reporters/ImageGenerationReporter.ts new file mode 100644 index 00000000..fb8820cd --- /dev/null +++ b/src/tool-calls/reporters/ImageGenerationReporter.ts @@ -0,0 +1,105 @@ +import type * as acp from "@agentclientprotocol/sdk"; +import type {ThreadItem} from "../../app-server/v2"; +import {textContent} from "../AcpToolCallRenderer"; +import type {StandardToolCallFields, ToolFacts} from "../ToolFacts"; + +type ImageGenerationItem = ThreadItem & {type: "imageGeneration"}; + +const TITLE = "Image generation"; + +/** + * Reports a Codex image generation. + * The revised prompt and the image, with the saved path as its URI, are the result. Each goes once to `content`. + */ +export class ImageGenerationReporter { + static started(item: ImageGenerationItem): ToolFacts { + return { + toolCallId: item.id, + report: "start", + kind: "other", + title: TITLE, + status: "in_progress", + standard: {rawInput: {id: item.id}}, + }; + } + + static completed(item: ImageGenerationItem): ToolFacts { + return { + toolCallId: item.id, + report: "update", + status: terminalStatus(item.status), + result: imageResult(item), + standard: standardResult(item), + }; + } + + /** The only report of a generation whose start the adapter did not see, for example in the history. */ + static whole(item: ImageGenerationItem, options?: {terminalStatus?: boolean}): ToolFacts { + return { + toolCallId: item.id, + report: "start", + kind: "other", + title: TITLE, + status: options?.terminalStatus ? terminalStatus(item.status) : toolStatus(item.status), + result: imageResult(item), + standard: standardResult(item), + }; + } +} + +/** + * A client that is not AIR gets the image only when Codex sent its data, and the item fields in `rawOutput`. + */ +function standardResult(item: ImageGenerationItem): StandardToolCallFields { + const rawOutput: Record = { + status: item.status, + revisedPrompt: item.revisedPrompt, + result: item.result, + }; + if ("savedPath" in item) rawOutput["savedPath"] = item.savedPath ?? null; + return { + content: imageResult(item) + .filter(content => content.type !== "content" || content.content.type !== "resource_link"), + rawOutput, + }; +} + +function imageResult(item: ImageGenerationItem): acp.ToolCallContent[] { + const result: acp.ToolCallContent[] = []; + if (item.revisedPrompt && item.revisedPrompt.trim() !== "") { + result.push(textContent(`Revised prompt: ${item.revisedPrompt}`)); + } + const savedPath = item.savedPath && item.savedPath.trim() !== "" ? item.savedPath : undefined; + if (item.result.trim() !== "") { + result.push({ + type: "content", + content: { + type: "image", + data: item.result, + mimeType: "image/png", + ...(savedPath === undefined ? {} : {uri: savedPath}), + }, + }); + } else if (savedPath !== undefined) { + result.push({type: "content", content: {type: "resource_link", name: savedPath, uri: savedPath}}); + } + return result; +} + +function toolStatus(status: string): acp.ToolCallStatus { + switch (status) { + case "generating": + case "in_progress": + case "inProgress": + case "incomplete": + return "in_progress"; + case "failed": + return "failed"; + default: + return "completed"; + } +} + +function terminalStatus(status: string): acp.ToolCallStatus { + return status === "failed" ? "failed" : "completed"; +} diff --git a/src/tool-calls/reporters/ImageViewReporter.ts b/src/tool-calls/reporters/ImageViewReporter.ts new file mode 100644 index 00000000..6944aa8b --- /dev/null +++ b/src/tool-calls/reporters/ImageViewReporter.ts @@ -0,0 +1,21 @@ +import type {ThreadItem} from "../../app-server/v2"; +import type {ToolFacts} from "../ToolFacts"; + +type ImageViewItem = ThreadItem & {type: "imageView"}; + +/** Reports a Codex image view. The client shows the viewed image as a link. */ +export class ImageViewReporter { + static viewed(item: ImageViewItem): ToolFacts { + return { + toolCallId: item.id, + report: "start", + name: "view_image", + kind: "read", + title: `View Image ${item.path}`, + status: "completed", + result: [{type: "content", content: {type: "resource_link", name: item.path, uri: item.path}}], + locations: [item.path], + input: {path: item.path}, + }; + } +} diff --git a/src/tool-calls/reporters/McpStartupReporter.ts b/src/tool-calls/reporters/McpStartupReporter.ts new file mode 100644 index 00000000..cb0121d1 --- /dev/null +++ b/src/tool-calls/reporters/McpStartupReporter.ts @@ -0,0 +1,32 @@ +import {randomUUID} from "node:crypto"; +import type {McpStartupCompleteEvent} from "../../app-server/McpStartupCompleteEvent"; +import {textContent} from "../AcpToolCallRenderer"; +import type {ToolFacts} from "../ToolFacts"; + +/** Reports the MCP servers that failed to start. Each report is a new, failed tool call. */ +export class McpStartupReporter { + static failures(event: McpStartupCompleteEvent): ToolFacts[] { + return [ + ...event.failed.map(server => failure( + server.server, + `[codex-acp forwarded startup error] MCP server \`${server.server}\` failed to start: ${server.error}`, + )), + ...event.cancelled.map(server => failure( + server, + `[codex-acp forwarded startup error] MCP server \`${server}\` startup was cancelled.`, + )), + ]; + } +} + +function failure(serverName: string, message: string): ToolFacts { + return { + // A unique id, so that a later report for the same server cannot replace this one. + toolCallId: `mcp_startup.${encodeURIComponent(serverName)}.${randomUUID()}`, + report: "start", + kind: "other", + title: `mcp__${serverName}__startup`, + status: "failed", + result: [textContent(message)], + }; +} diff --git a/src/tool-calls/reporters/McpToolReporter.ts b/src/tool-calls/reporters/McpToolReporter.ts new file mode 100644 index 00000000..d6f1bab7 --- /dev/null +++ b/src/tool-calls/reporters/McpToolReporter.ts @@ -0,0 +1,51 @@ +import type {ThreadItem} from "../../app-server/v2"; +import type {ToolFacts} from "../ToolFacts"; +import {toToolStatus} from "./ToolStatus"; + +type McpToolCallItem = ThreadItem & {type: "mcpToolCall"}; + +/** + * Reports a Codex MCP tool call. + * Every client gets the whole Codex result and error in `rawOutput = {result, error}`, and no `content`. + * AIR shows the text of `rawOutput.result` and `rawOutput.error.message`. + */ +export class McpToolReporter { + static started(item: McpToolCallItem): ToolFacts { + return { + toolCallId: item.id, + report: "start", + kind: "execute", + title: `mcp.${item.server}.${item.tool}`, + status: toToolStatus(item.status), + input: mcpInput(item), + ...resultFacts(item), + mcp: true, + }; + } + + static completed(item: McpToolCallItem): ToolFacts { + return { + toolCallId: item.id, + report: "update", + status: item.status === "completed" ? "completed" : "failed", + input: mcpInput(item), + ...resultFacts(item), + }; + } + + /** + * MCP progress text, trimmed, for a client that is not AIR. + * AIR does not show MCP progress, so the report is empty for AIR and the adapter sends nothing. + */ + static progress(itemId: string, message: string): ToolFacts { + return {toolCallId: itemId, report: "update", standard: {mcpProgress: message.trim()}}; + } +} + +function mcpInput(item: McpToolCallItem): Record { + return {server: item.server, tool: item.tool, arguments: item.arguments}; +} + +function resultFacts(item: McpToolCallItem): Pick { + return item.result === null && item.error === null ? {} : {opaqueResult: {result: item.result, error: item.error}}; +} diff --git a/src/tool-calls/reporters/PlanReviewReporter.ts b/src/tool-calls/reporters/PlanReviewReporter.ts new file mode 100644 index 00000000..23a441fd --- /dev/null +++ b/src/tool-calls/reporters/PlanReviewReporter.ts @@ -0,0 +1,56 @@ +import type * as acp from "@agentclientprotocol/sdk"; +import type {CompletedPlan} from "../../CodexEventHandler"; +import type {AcpToolCallRenderer} from "../AcpToolCallRenderer"; +import type {ToolFacts} from "../ToolFacts"; + +const IMPLEMENT_PLAN_OPTION_ID = "implement_plan"; +const REVISE_PLAN_OPTION_ID = "revise_plan"; + +/** + * Reports the approval of a completed Codex plan. + * Every client gets the plan text in `rawInput.plan`. + * AIR reads it there for the summary and the approval card of the plan review. + */ +export class PlanReviewReporter { + static permissionRequest( + sessionId: string, + plan: CompletedPlan, + renderer: AcpToolCallRenderer, + ): acp.RequestPermissionRequest { + return { + sessionId, + toolCall: renderer.renderPermissionToolCall({ + toolCallId: planReviewToolCallId(plan), + title: "Implement this plan?", + kind: "switch_mode", + status: "pending", + input: {plan: plan.text}, + }), + options: [ + {optionId: IMPLEMENT_PLAN_OPTION_ID, name: "Yes, implement this plan", kind: "allow_once"}, + { + optionId: REVISE_PLAN_OPTION_ID, + name: "No, and tell Codex what to do differently", + kind: "reject_once", + }, + ], + }; + } + + static approved(response: acp.RequestPermissionResponse): boolean { + return response.outcome.outcome === "selected" && response.outcome.optionId === IMPLEMENT_PLAN_OPTION_ID; + } + + static decided(plan: CompletedPlan, approved: boolean): ToolFacts { + return { + toolCallId: planReviewToolCallId(plan), + report: "update", + status: "completed", + opaqueResult: approved ? "User approved the plan." : "User kept the session in plan mode.", + }; + } +} + +export function planReviewToolCallId(plan: CompletedPlan): string { + return `plan-review:${plan.itemId}`; +} diff --git a/src/tool-calls/reporters/SandboxPermissionReporter.ts b/src/tool-calls/reporters/SandboxPermissionReporter.ts new file mode 100644 index 00000000..2833ef28 --- /dev/null +++ b/src/tool-calls/reporters/SandboxPermissionReporter.ts @@ -0,0 +1,61 @@ +import type * as acp from "@agentclientprotocol/sdk"; +import type {AdditionalPermissionProfile, RequestPermissionProfile} from "../../app-server/v2"; +import {textContent} from "../AcpToolCallRenderer"; +import type {PermissionToolFacts} from "../ToolFacts"; + +/** Reports a Codex request for additional sandbox permissions. It is a new tool call. */ +export class SandboxPermissionReporter { + static permission( + itemId: string, + cwd: string, + environmentId: string | null, + permissions: RequestPermissionProfile, + ): PermissionToolFacts { + const content = permissionProfileContent(permissions); + return { + toolCallId: itemId, + name: "request_permissions", + kind: "other", + status: "pending", + title: "Additional sandbox permissions", + input: {permissions, cwd, environmentId}, + locations: permissionProfilePaths(permissions), + ...(content.length > 0 ? {result: content} : {}), + }; + } +} + +export function permissionProfilePaths( + permissions?: RequestPermissionProfile | AdditionalPermissionProfile | null, +): string[] { + const fileSystem = permissions?.fileSystem; + return [...new Set([ + ...(fileSystem?.read ?? []), + ...(fileSystem?.write ?? []), + ...(fileSystem?.entries ?? []).flatMap(entry => entry.path.type === "path" ? [entry.path.path] : []), + ])]; +} + +/** The requested permissions that have no path, as text. */ +export function permissionProfileContent( + permissions: RequestPermissionProfile | AdditionalPermissionProfile, +): acp.ToolCallContent[] { + const lines: string[] = []; + const networkEnabled = permissions.network?.enabled; + if (networkEnabled !== null && networkEnabled !== undefined) { + lines.push(networkEnabled ? "Enable network access" : "Disable network access"); + } + for (const entry of permissions.fileSystem?.entries ?? []) { + switch (entry.path.type) { + case "glob_pattern": + lines.push(`${entry.access} filesystem pattern ${entry.path.pattern}`); + break; + case "special": + lines.push(`${entry.access} Codex filesystem scope ${JSON.stringify(entry.path.value)}`); + break; + case "path": + break; + } + } + return lines.length > 0 ? [textContent(lines.join("\n"))] : []; +} diff --git a/src/tool-calls/reporters/SubagentActivityReporter.ts b/src/tool-calls/reporters/SubagentActivityReporter.ts new file mode 100644 index 00000000..75372d66 --- /dev/null +++ b/src/tool-calls/reporters/SubagentActivityReporter.ts @@ -0,0 +1,40 @@ +import type {ThreadItem} from "../../app-server/v2"; +import type {ToolFacts} from "../ToolFacts"; + +type SubAgentActivityItem = ThreadItem & {type: "subAgentActivity"}; + +/** Reports a Codex subagent activity, for a client without native subagent sessions. */ +export class SubagentActivityReporter { + static activity( + item: SubAgentActivityItem, + status: "in_progress" | "completed", + report: ToolFacts["report"], + ): ToolFacts { + const name = item.agentPath.split("/").filter(Boolean).at(-1) ?? "subagent"; + return { + toolCallId: item.id, + report, + ...(report === "start" ? {kind: "other" as const, title: activityTitle(item.kind, name)} : {}), + status, + input: { + agentThreadId: item.agentThreadId, + agentPath: item.agentPath, + activityKind: item.kind, + }, + subagent: true, + }; + } +} + +function activityTitle(kind: SubAgentActivityItem["kind"], name: string): string { + switch (kind) { + case "started": + return `Start subagent ${name}`; + case "interacted": + return `Interact with subagent ${name}`; + case "interrupted": + return `Interrupt subagent ${name}`; + case "completed": + return `Complete subagent ${name}`; + } +} diff --git a/src/tool-calls/reporters/ToolStatus.ts b/src/tool-calls/reporters/ToolStatus.ts new file mode 100644 index 00000000..a5f24cad --- /dev/null +++ b/src/tool-calls/reporters/ToolStatus.ts @@ -0,0 +1,24 @@ +import type * as acp from "@agentclientprotocol/sdk"; +import type { + CollabAgentToolCallStatus, + CommandExecutionStatus, + DynamicToolCallStatus, + McpToolCallStatus, + PatchApplyStatus, +} from "../../app-server/v2"; + +type CodexItemStatus = CommandExecutionStatus | PatchApplyStatus | McpToolCallStatus | DynamicToolCallStatus + | CollabAgentToolCallStatus; + +export function toToolStatus(status: CodexItemStatus): acp.ToolCallStatus { + switch (status) { + case "inProgress": + return "in_progress"; + case "completed": + return "completed"; + case "failed": + case "declined": + case "interrupted": + return "failed"; + } +} diff --git a/src/tool-calls/reporters/WebSearchReporter.ts b/src/tool-calls/reporters/WebSearchReporter.ts new file mode 100644 index 00000000..6a73d1ba --- /dev/null +++ b/src/tool-calls/reporters/WebSearchReporter.ts @@ -0,0 +1,55 @@ +import type {ThreadItem} from "../../app-server/v2"; +import type {ToolFacts} from "../ToolFacts"; + +type WebSearchItem = ThreadItem & {type: "webSearch"}; + +/** Reports a Codex web search. */ +export class WebSearchReporter { + static started(item: WebSearchItem): ToolFacts { + return {...facts(item, "start"), kind: "search", status: "in_progress"}; + } + + static completed(item: WebSearchItem): ToolFacts { + return {...facts(item, "update"), status: "completed"}; + } + + /** The replay of a web search. Every client gets the same `rawInput`. */ + static history(item: WebSearchItem): ToolFacts { + const {standard: _standard, ...replayed} = facts(item, "start"); + return {...replayed, kind: "search", status: "completed"}; + } +} + +function facts(item: WebSearchItem, report: ToolFacts["report"]): ToolFacts { + return { + toolCallId: item.id, + report, + title: webSearchTitle(item), + input: {query: item.query, action: item.action}, + // A live report of a client that is not AIR also names the item. + standard: {rawInput: {type: item.type, id: item.id, query: item.query, action: item.action}}, + }; +} + +export function webSearchTitle(item: WebSearchItem): string { + const action = item.action; + if (!action) { + return item.query ? `Web search: ${item.query}` : "Web search"; + } + switch (action.type) { + case "search": { + const queries = action.queries?.filter((query) => query && query.length > 0) ?? []; + const query = action.query ?? (queries.length > 0 ? queries.join(", ") : null) ?? item.query; + return query ? `Web search: ${query}` : "Web search"; + } + case "openPage": + return action.url ? `Open page: ${action.url}` : "Open page"; + case "findInPage": { + const pattern = action.pattern ? ` for '${action.pattern}'` : ""; + const url = action.url ? ` in ${action.url}` : ""; + return `Find in page${pattern}${url}`.trim(); + } + case "other": + return "Web search"; + } +} From 9f56c2aa782bcc5beca4e8675c89290ddf934b82 Mon Sep 17 00:00:00 2001 From: Nikita Ashikhmin Date: Wed, 23 Sep 2026 15:17:10 +0400 Subject: [PATCH 04/14] docs: describe the AIR extensions and the tool call contract in docs/air-extensions.md Merge the AIR extension docs into one file: the diff patch, the agent file change report, async tasks, goals, permissions, recommended config values, and the tool call contract. The old files are removed, and the README and the other docs link to the new sections. The compatibility rule limits the AIR extensions to AIR. A client that is not AIR gets the fields that the adapter sent before these extensions, with only the listed differences. The docs state the title and commandTitle exceptions of the contract, the terminal channel and the _meta filter of each client, and the Zed conventions. The plan of codex-acp is always streamed text. AIR also accepts a plan as a path to a file that it follows, but Codex keeps the plan only as text, so this adapter does not declare the planFile capability. --- README.md | 17 +- docs/agent-file-change-report.md | 60 -- docs/air-extensions.md | 990 ++++++++++++++++++++ docs/async-tasks.md | 42 - docs/diff-patch-extension.md | 124 --- docs/goal-extension.md | 57 -- docs/permission-extension.md | 180 ---- docs/recommended-config-values-extension.md | 53 -- docs/session-compaction.md | 2 +- docs/subagent-sessions.md | 2 +- 10 files changed, 1002 insertions(+), 525 deletions(-) delete mode 100644 docs/agent-file-change-report.md create mode 100644 docs/air-extensions.md delete mode 100644 docs/async-tasks.md delete mode 100644 docs/diff-patch-extension.md delete mode 100644 docs/goal-extension.md delete mode 100644 docs/permission-extension.md delete mode 100644 docs/recommended-config-values-extension.md diff --git a/README.md b/README.md index 1864792d..bff3067f 100644 --- a/README.md +++ b/README.md @@ -10,14 +10,17 @@ Use [OpenAI Codex](https://github.com/openai/codex) from [Agent Client Protocol] - ChatGPT, API key, and client-provided custom gateway authentication. - Model, reasoning effort, fast mode, approval, and sandbox mode configuration. -- Concrete recommended model and reasoning-effort values through the opt-in [AIR recommended config values](docs/recommended-config-values-extension.md) capability. +- Concrete recommended model and reasoning-effort values through the opt-in [AIR recommended config values](docs/air-extensions.md#recommended-config-values) capability. - Text prompts, embedded context, images, resource links, and additional workspace directories. -- Shell command, file change, [permission request](docs/permission-extension.md), MCP tool call, terminal output, reasoning, plan, web search, image generation, image view, token usage, and review events. -- Compact file changes through the negotiated [AIR diff patch extension](docs/diff-patch-extension.md). +- Shell command, file change, [permission request](docs/air-extensions.md#permission-presentation), MCP tool call, terminal output, reasoning, plan, web search, image generation, image view, token usage, and review events. +- Compact file changes through the negotiated [AIR diff patch extension](docs/air-extensions.md#diff-patch). +- One standard tool call shape for every client, with each fact in one field, as the [ACP tool call contract](docs/air-extensions.md#tool-call-contract) defines. - [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). -- A per-turn [agent file-change report](docs/agent-file-change-report.md) after capability negotiation. +- [Background terminal tasks](docs/air-extensions.md#async-tasks) in AIR, with task status and targeted stop support after capability negotiation. +- Session-scoped long-running goals through the provider-neutral [goal extension](docs/air-extensions.md#goal). +- 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. - 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. @@ -99,7 +102,7 @@ See [docs/subagent-sessions.md](docs/subagent-sessions.md) for the negotiation, Codex can keep a shell command running after a turn continues. AIR clients can show this work in the Async Tasks panel and stop one command. -See [docs/async-tasks.md](docs/async-tasks.md) for the capability, lifecycle events, and stop request. +See [AIR extensions](docs/air-extensions.md#async-tasks) for the capability, lifecycle events, and stop request. ## License diff --git a/docs/agent-file-change-report.md b/docs/agent-file-change-report.md deleted file mode 100644 index 56333251..00000000 --- a/docs/agent-file-change-report.md +++ /dev/null @@ -1,60 +0,0 @@ -# Agent file-change report - -Standard ACP can describe a change from one tool call. It has no complete file list for one prompt turn. - -This adapter supports the version-1 `agentFileChangeReport` extension. The client and adapter must both advertise it in `_meta.jetbrains.air.capabilities` during `initialize`. - -The client adds this object to `session/prompt`: - -```json -{ - "_meta": { - "jetbrains": { - "air": { - "agentFileChangeReportRequest": { - "version": 1, - "requestId": "a-unique-request-id" - } - } - } - } -} -``` - -The request identifier has 1 to 128 characters. It can contain ASCII letters, digits, `.`, `_`, `:`, and `-`. - -The adapter derives the report from Codex's final aggregated `turn/diff/updated` snapshot for the main turn. It does not run an additional model turn. The adapter keeps Codex's experimental `cwd_relative_turn_diffs` feature disabled so paths have the standard Git-root-relative form. In Codex 0.154, this snapshot tracks `apply_patch` mutations but can omit same-content renames and changes made through shell commands, version-control commands, generators, or child processes. The adapter therefore publishes these reports with `declaredComplete: false` and explains the limitation in `uncertainty`. - -The adapter sends one `session_info_update` before the `PromptResponse`: - -```json -{ - "sessionUpdate": "session_info_update", - "_meta": { - "jetbrains": { - "air": { - "version": 1, - "agentFileChangeReport": { - "version": 1, - "requestId": "a-unique-request-id", - "status": "reported", - "paths": ["/workspace/src/App.ts"], - "declaredComplete": false, - "truncated": false, - "uncertainty": "Codex turn diffs may omit same-content renames and changes made outside apply_patch, including shell commands, version-control commands, generators, and child processes." - } - } - } - } -} -``` - -Each path is an absolute normalized path in the working directory or an additional workspace directory. The report contains no file content, diff, line count, or path order guarantee. - -The adapter sends at most 1,024 paths. Each path has at most 4,096 characters. The serialized report has at most 256 KiB. The optional uncertainty has at most 2,000 characters. Turn-diff snapshots larger than 8 MiB are rejected before parsing. - -The adapter marks the result unavailable when the prompt is cancelled, the turn diff is invalid, no provider turn ran, or the provider failed. The corresponding reasons are `cancelled`, `invalidOutput`, `notReported`, and `providerError`. The `timeout` reason remains part of the version-1 wire contract for backward compatibility but is not produced by this implementation. Report-generation failures do not change the main prompt outcome; failures of the main provider turn still follow the normal prompt error behavior. - -The client must match the request identifier. It must ignore a duplicate, stale, malformed, or unavailable report. - -Rollback is outside this extension. This adapter does not advertise an `undo` or `rollback` command. diff --git a/docs/air-extensions.md b/docs/air-extensions.md new file mode 100644 index 00000000..1e1dc393 --- /dev/null +++ b/docs/air-extensions.md @@ -0,0 +1,990 @@ +# AIR extensions in codex-acp + +Status: Experimental + +This document is the wire contract of the JetBrains AIR extensions that `codex-acp` implements. +It describes only this adapter. + +## Contents + +- [Purpose and scope](#purpose-and-scope) +- [Compatibility rule](#compatibility-rule) +- [Negotiation](#negotiation) +- [AIR metadata keys](#air-metadata-keys) +- [JetBrains shared keys](#jetbrains-shared-keys) +- [Zed conventions](#zed-conventions) +- [Tool call contract](#tool-call-contract) +- [Codex items and ACP fields](#codex-items-and-acp-fields) +- [Diff patch](#diff-patch) +- [Permission presentation](#permission-presentation) +- [Plan content delta and plan review](#plan-content-delta-and-plan-review) +- [Goal](#goal) +- [Recommended config values](#recommended-config-values) +- [Async tasks](#async-tasks) +- [Agent file-change report](#agent-file-change-report) +- [Session failure](#session-failure) +- [Native subagent sessions](#native-subagent-sessions) +- [Context compaction](#context-compaction) +- [Session fork point](#session-fork-point) +- [Presentation hints](#presentation-hints) +- [Removed keys](#removed-keys) + +## Purpose and scope + +AIR is the ACP client that JetBrains builds. +AIR needs some data that standard ACP does not define. +This adapter sends that data as opt-in extensions under the `_meta.jetbrains.air` namespace. + +`jetbrains` owns the non-standard contract. +`air` names the client whose rendering rules the contract follows. +The two levels keep other JetBrains ACP clients from reading this metadata by accident. + +Each extension is experimental. +Each extension is shaped so that it can become a first-class ACP API later. + +## Compatibility rule + +Only AIR gets the AIR extensions. +A client is AIR when it declares `initialize.clientCapabilities._meta.jetbrains.air`. + +A client that does not declare it, for example Zed or a plain ACP client, gets the fields that the adapter sent before these extensions. +The fields have the same values in the same places. +Only these differences are allowed: + +- A `tool_call_update` omits a top-level field that did not change since the last report of the same tool call. + The permission request of a tool call counts as a report. ACP clients merge an update into the stored tool call. + After a cancelled or failed permission request, the next update carries every field again. + ACP defines no merge for `_meta` keys, so the `_meta` of each report keeps every key that the adapter sent before. +- Bug fixes: a unique MCP startup tool call id, the result of a dynamic tool in `content`, + and no output after a tool call ended. +- The client gets no AIR-only key. + That is no `_meta.jetbrains.air` key and none of the earlier keys in [Removed keys](#removed-keys). + +The keys of Zed, of upstream ACP, and of other JetBrains teams stay as they were. +See [JetBrains shared keys](#jetbrains-shared-keys) and [Zed conventions](#zed-conventions). +[Codex items and ACP fields](#codex-items-and-acp-fields) lists the fields of each client. + +## Negotiation + +### Client declaration + +AIR declares its capabilities in the `initialize` request: + +```json +{ + "clientCapabilities": { + "_meta": { + "jetbrains": { + "air": { + "version": 1, + "capabilities": ["diffPatch", "rawInputRendering", "planContentDelta"] + } + } + } + } +} +``` + +The adapter accepts a capability only when all of these are true: + +- `version` is an integer and is at least `1`. +- `capabilities` is an array. +- The array contains the exact capability name. + +A malformed declaration enables no capability. +The adapter reads the declaration once, in `initialize`. + +### Agent declaration + +The `initialize` response carries the agent side of the extension only when the client is AIR: + +```json +{ + "_meta": { + "steering": { "supported": true }, + "jetbrains": { + "air": { + "version": 1, + "goal": { + "version": 1, + "controlMethod": "_session/goal", + "actions": ["set", "pause", "resume", "clear"] + }, + "capabilities": [ + "sessionFailure", + "diffPatch", + "agentFileChangeReport", + "nativeSubagentSessions", + "asyncTasks", + "recommendedValue", + "rawInputRendering", + "planContentDelta" + ] + } + } + } +} +``` + +The agent list does not depend on the client capability list. +An extension is active only when the client declared its capability. +A client that is not AIR gets no `jetbrains` key and no `goal` key in the `initialize` response. + +### Capabilities + +| Capability | What the adapter does when the client declares it | Section | +| --- | --- | --- | +| `diffPatch` | Sends a file change as one Git patch in the diff block. | [Diff patch](#diff-patch) | +| `rawInputRendering` | Sends no display copy of readable input in `content`. The client renders `rawInput`. | [Tool call contract](#tool-call-contract) | +| `planContentDelta` | Streams a Markdown plan as appended text in `plan_update`. | [Plan content delta and plan review](#plan-content-delta-and-plan-review) | +| `recommendedValue` | Adds the Codex recommendation to the model and effort selectors. | [Recommended config values](#recommended-config-values) | +| `asyncTasks` | Publishes background terminal commands as async tasks. | [Async tasks](#async-tasks) | +| `agentFileChangeReport` | Accepts a report request on `session/prompt` and sends the changed file list. | [Agent file-change report](#agent-file-change-report) | +| `sessionFailure` | Sends warnings and errors as typed transcript records. | [Session failure](#session-failure) | +| `nativeSubagentSessions` | Reports a Codex subagent as a native ACP child session. | [Native subagent sessions](#native-subagent-sessions) | + +The goal extension has no client capability. +The agent advertises the `goal` object, and the client uses the control method when it wants to. + +## AIR metadata keys + +Every payload goes into `_meta.jetbrains.air`, next to `version: 1`. +The adapter merges a payload into an existing `_meta` and keeps the other namespaces. +The adapter sends these keys only to AIR. "AIR" in the gate column means that the key needs no AIR capability. + +| Key | Message and field path | Shape | Gate | +| --- | --- | --- | --- | +| `capabilities` | `initialize` response `_meta.jetbrains.air` | string array | AIR | +| `goal` | `initialize` response `_meta.jetbrains.air` | `{version: 1, controlMethod, actions}` | AIR | +| `goal` | `session_info_update._meta.jetbrains.air` | goal snapshot or `null` | AIR | +| `diffPatch` | tool call `content[]` of type `diff`, `_meta.jetbrains.air` | `{version: 1, format: "git_patch", text}` | `diffPatch` | +| `contentDelta` | `plan_update._meta.jetbrains.air` | string | `planContentDelta` | +| `permission` | `session/request_permission` request `_meta.jetbrains.air` | `{version: 1, title, description?}` | AIR | +| `permission` | permission option `_meta.jetbrains.air` | `{version: 1, description}` | AIR | +| `recommendedValue` | `model` and `reasoning_effort` config options, `_meta.jetbrains.air` | option value string | `recommendedValue` | +| `asyncTasks` | `tool_call_update._meta.jetbrains.air` | `{backgrounded: true}` | `asyncTasks` | +| `agentFileChangeReportRequest` | `session/prompt` request `_meta.jetbrains.air` (client to agent) | `{version: 1, requestId}` | `agentFileChangeReport` | +| `agentFileChangeReport` | `session_info_update._meta.jetbrains.air` | report object | `agentFileChangeReport` | +| `sessionFailure` | `session_info_update._meta.jetbrains.air` or `PromptResponse._meta.jetbrains.air` | failure record | `sessionFailure` | +| `subagent` | `tool_call._meta.jetbrains.air` of a `spawnAgent` collaboration item or a subagent activity item | `true` | AIR | +| `contextCompaction` | `tool_call` and `tool_call_update` `_meta.jetbrains.air` of the synthetic compaction tool call | `{version: 1}` | AIR, when the client has no ACP compaction | +| `fork` | `session/fork` request `_meta.jetbrains.air` (client to agent) | `{version: 1, messageId, messageFingerprint?, messageOccurrence?}` | none | +| `phase` | `agent_message_chunk._meta.jetbrains.air` | Codex message phase string | AIR | +| `kind` | session mode `_meta.jetbrains.air` and `mode` config option value `_meta.jetbrains.air` | `standard`, `auto_review`, or `full_access` | AIR | +| `commandAction` | available command `_meta.jetbrains.air` | command action object | AIR | + +## JetBrains shared keys + +These keys are JetBrains conventions outside the AIR namespace. +Other JetBrains ACP clients and adapters use them too. +The adapter keeps them where they are. + +| Key | Where | Meaning | +| --- | --- | --- | +| `terminal_output_delta` | client `initialize` `clientCapabilities._meta.terminal_output_delta: true`; tool call `_meta.terminal_output_delta = {terminal_id, data}` | The client appends each chunk of command output. AIR gets it only when it declares it. A client that is not AIR also gets it when it declares no other channel, see [Zed conventions](#zed-conventions). | +| `terminal_input` | tool call `_meta.terminal_input = {terminal_id, data}` | Text that was written to the stdin of a running command. It is not output. Only AIR gets it. | +| `mcp_output_delta` | tool call `_meta.mcp_output_delta = {data}` | MCP progress text to append, trimmed. AIR does not get it. | +| `is_mcp_tool_call` | tool call `_meta.is_mcp_tool_call: true` | The tool call is an MCP tool call. | +| `is_mcp_tool_approval` | `session/request_permission` request `_meta.is_mcp_tool_approval: true` | The permission request approves an MCP tool call. | +| `steering` | `initialize` response `_meta.steering = {supported: true}` | The agent accepts `_session/steering` for a running turn. | +| `quota` | `PromptResponse._meta.quota` | Token usage and rate limits of the turn. | +| `authStatus` | `initialize` response `agentCapabilities._meta.authStatus` | The agent pushes `_auth/status_update`. The object carries no payload. | + +## Zed conventions + +The adapter keeps these Zed conventions for every client: + +- A command tool call has `content: [{type: "terminal", terminalId}]` and `_meta.terminal_info = {cwd, terminal_id}`. +- The end of a command sends `_meta.terminal_exit = {exit_code, signal: null, terminal_id}`. + +The terminal id is the tool call id. +The output channel follows the declaration of the client: + +- A client that declares `terminal_output_delta` gets the output chunks of every command in `_meta.terminal_output_delta`. +- Otherwise, a client that declares `terminal_output` gets output chunks in `_meta.terminal_output = {terminal_id, data}` + for a command that shows a terminal. Zed declares `terminal_output: true` and `terminal-auth: true`. + The chunks of a read, search, or list command go to `_meta.terminal_output_delta`. +- A client that is not AIR and declares neither gets every output chunk in `_meta.terminal_output_delta`. + This is the behavior of the adapter before the tool call contract. +- AIR that declares neither gets no output chunks. + +A client that is not AIR also keeps these fields: + +- The end of a command carries `rawOutput = {formatted_output, exit_code}`, with the whole output. + A client that declares `terminal_output_delta` does not get it for a live command. + A replayed command always carries it. +- Output that did not stream goes in one chunk at the end, for a command that shows a terminal. + A live read, search, or list command sends this chunk only to a client that declares `terminal_output_delta`. +- The text that was written to the stdin of a command goes to the output channel as `\n\n`. +- The output of a read, search, or list command is in `rawOutput.formatted_output`, not in `content`. + +So a plain ACP client gets the output chunks in `_meta.terminal_output_delta`, +and it sees the output of every command in `rawOutput.formatted_output` when the command ends. + +AIR gets no `rawOutput.formatted_output` and no `rawOutput.exit_code`. +AIR gets stdin in `_meta.terminal_input`, and the output of a read, search, or list command once in `content`. + +This adapter offers no `terminal-auth` authentication method. + +## Tool call contract + +AIR gets this contract. Each fact goes in exactly one field. +A client that is not AIR keeps the fields of the adapter before this contract, +see [Codex items and ACP fields](#codex-items-and-acp-fields). + +| Fact | The only field that carries it | +| --- | --- | +| Tool parameters | `rawInput`, once they are complete, and again only when they change | +| File text of an edit | the diff in `content`, a patch when `diffPatch` is negotiated, never also in `rawInput` | +| Result to show (read text, search hits, review verdict) | `content` | +| Result without a display form (MCP result and error, elicitation action) | `rawOutput` | +| Command output | the terminal channel that the client negotiated | +| MCP progress | none, AIR does not show it | +| Status, title, kind, locations | the field itself, only when it changes | + +Rules: + +- An update carries only the fields that changed since the last report of that tool call. +- Input is never copied into `title` or `_meta`, with one exception. + The `title` of a command, a read, a search, or an MCP call names the command, the path, or the query. + Zed shows the title as that label. +- Some input is text that the user reads: the prompt of a subagent, a reviewed action, an elicitation question. + AIR with `rawInputRendering` gets no copy of it in `content`. + AIR without `rawInputRendering` gets one display copy of that input in `content`. +- Output is never copied into `rawOutput` when it is in `content`. + Output is never copied into `content` when it is in the terminal channel. +- `title` is a short label. It is not the output. +- Streamed message text is not sent again in full when the complete message arrives. This applies to subagents too. + +### Adapter structure + +- A `ToolReporter` per Codex item type reads the event once and produces `ToolFacts`. +- One `AcpToolCallRenderer` turns the facts into ACP fields. + It reads the client choices from one `ClientCapabilities` object. +- `ToolFacts.standard` holds the fields of a client that is not AIR, where they differ from the contract fields. + The renderer applies them for such a client and sends it no AIR key. +- A changed-field filter drops the fields that an earlier report of the same tool call already sent. + It applies to the top-level fields for every client. + It drops an unchanged `_meta` key only for AIR, because ACP defines no merge for `_meta` keys. +- The `jetbrains.air` capabilities are AIR capabilities. + The adapter does not treat them as a generic client feature. + +## Codex items and ACP fields + +The table shows the fields that differ between AIR and the other clients. +A field that the table does not name is the same for every client. +The other clients get the same fields as before the AIR extensions. + +| Codex item | AIR | Other clients | +| --- | --- | --- | +| `commandExecution` with one `read`, `search`, or `listFiles` action | `kind` `read` or `search`, a title that names the path or the query, `locations`. The output goes to `content` once, at completion. No terminal. | The same start. The output is in `rawOutput.formatted_output` at completion. | +| Any other `commandExecution` | `kind: execute`, `title` is the command, `rawInput = {command, cwd}`, a terminal. Output streams to `_meta.terminal_output_delta`. Stdin goes to `_meta.terminal_input`. The end sends `_meta.terminal_exit`. With `asyncTasks`, a command that keeps running gets `_meta.jetbrains.air.asyncTasks.backgrounded`. | The same start. Output and stdin follow [Zed conventions](#zed-conventions). The end also carries `rawOutput.formatted_output` and `rawOutput.exit_code`. | +| `fileChange` | `kind: edit`, `title: "Editing files"`, one `diff` block per changed file with `oldText` and `newText`. The block has `_meta.kind` `add`, `update`, or `delete`. With `diffPatch`, each block carries a Git patch. | The same, without a patch. | +| `mcpToolCall` | `kind: execute`, `title: "mcp.."`, `rawInput = {server, tool, arguments}`, `_meta.is_mcp_tool_call`. No `content`. `rawOutput = {result, error}` with the whole Codex result and error. AIR shows the text of `result` and `error.message`. No progress. | The same. The progress text goes to `_meta.mcp_output_delta`, trimmed. | +| `dynamicToolCall` | `name`, `kind: execute`, `title` is the tool, `rawInput = {arguments}`. The content items go to `content`. | The same. | +| `collabAgentToolCall`, without native subagent sessions | `kind: other`, `title` is the Codex tool name, `rawInput` holds the prompt, `senderThreadId`, `receiverThreadIds`, `agentsStates`, the model, and the effort. AIR recognizes a collaboration tool call by these three keys. Only `spawnAgent` gets `_meta.jetbrains.air.subagent: true`. Without `rawInputRendering`, one copy of the prompt in `content`. | `rawInput` also holds the Codex `status`. No `rawOutput`, no `content`, no `_meta`. | +| `subAgentActivity`, without native subagent sessions | `kind: other`, a title such as `Start subagent `, `rawInput = {agentThreadId, agentPath, activityKind}`, `_meta.jetbrains.air.subagent: true`. | The same, without `_meta`. | +| Guardian approval review | `toolCallId: guardian_assessment:`, `kind: think`, `title: "Guardian Review"`, `rawInput = {action}`. The verdict goes to `content`. Without `rawInputRendering`, one `Action: ...` text in `content`. | One text in `content` with the status, the action, the risk, the authorization, and the rationale. The start has the whole Codex event in `rawInput`, a later report in `rawOutput`. | +| MCP elicitation shown as a permission | A standalone tool call with `rawInput = {serverName, description, schema}` or `{serverName, description, url}`. Without `rawInputRendering`, one copy of the question in `content`. | The same, with the question in `content`. | +| `webSearch` | `kind: search`, a title that names the query or the page, `rawInput = {query, action}`. | A live report has `rawInput = {type, id, query, action}`. | +| `imageGeneration` | `kind: other`, `title: "Image generation"`. The revised prompt and the image go to `content`. A saved image without data goes to `content` as a resource link. | The start has `rawInput = {id}`. The end has `rawOutput = {status, revisedPrompt, result, savedPath}`, and no resource link. | +| `plan` item (the Markdown plan of plan mode) | With `planContentDelta`, appended text in `plan_update`. | `plan_update` snapshots when the client shows plans. Otherwise the whole plan in one `agent_message_chunk` when the plan item completes. | +| Turn plan (`turn/plan/updated`) | standard `plan` with entries | The same. | +| `contextCompaction` | `compaction_update` when the client declares `session.compaction`. Otherwise a synthetic tool call with `_meta.jetbrains.air.contextCompaction`. | The same, without `_meta`. | +| `agentMessage` | `agent_message_chunk` with `_meta.jetbrains.air.phase` when Codex reports a phase. | No `_meta`. | +| Command, file change, or sandbox permission request | See [Tool call of the request](#tool-call-of-the-request). | `kind`, `status: pending`, and a generic title such as `Run command` or `Edit files`, also for a started tool call. No `_meta`. | +| `imageView`, fuzzy search, MCP startup | standard shape. A fuzzy search that finds no file sends `locations: []`. | The same. | + +## Diff patch + +The diff patch extension lets the adapter send one compact Git patch instead of file text snapshots. +It applies to an ACP `diff` content block. + +### Activation + +The adapter uses patch mode only when the client declares `diffPatch`. +The agent advertises `diffPatch` to AIR. +Without the client declaration, the adapter sends the standard `oldText` and `newText` values. + +### Diff content + +Patch mode puts the payload at `_meta.jetbrains.air.diffPatch`: + +```json +{ + "type": "diff", + "path": "/workspace/src/App.ts", + "oldText": null, + "newText": "", + "_meta": { + "kind": "update", + "jetbrains": { + "air": { + "version": 1, + "diffPatch": { + "version": 1, + "format": "git_patch", + "text": "diff --git a/workspace/src/App.ts b/workspace/src/App.ts\n--- a/workspace/src/App.ts\n+++ b/workspace/src/App.ts\n@@ -1 +1 @@\n-old\n+new\n" + } + } + } + } +} +``` + +| Field | Type | Meaning | +| --- | --- | --- | +| `version` | integer | Must equal `1`. | +| `format` | string | Must equal `git_patch`. | +| `text` | string | One unified Git patch for the file of the block. | + +The patch rules: + +- The patch contains Git file headers and at least one `@@` hunk. +- Each header path is the absolute file path without its leading slash, with the `a/` or `b/` prefix. +- A Windows path uses forward slashes, for example `a/C:/work/App.ts`. +- The adapter quotes a path in C style when it contains a double quote, a backslash, or a control character, as Git does. + It does not quote non-ASCII characters. +- A `---` or `+++` line ends with a tab when its unquoted path contains a space. +- An added file has a `new file mode 100644` header and uses `/dev/null` as the old file header. +- A deleted file has a `deleted file mode 100644` header and uses `/dev/null` as the new file header. +- A moved file has `rename from` and `rename to` headers. The block `path` is the target path. +- The patch keeps the provider bytes, including a carriage return. +- A file without a final newline ends with the `\ No newline at end of file` marker. + +In patch mode, `oldText: null` and `newText: ""` are compatibility placeholders. +They are not file snapshots or changed fragments. +The receiver must use `diffPatch.text` as the change payload. +The receiver derives line counts and changed fragments from the patch. + +### Fallback + +The adapter sends the standard ACP diff when it cannot build a valid patch. +That diff contains meaningful `oldText` and `newText` values and has no `diffPatch`. +The adapter uses the fallback in these cases: + +- The file is empty, so no hunk can express it. +- The content is binary. The content is binary when its first 8000 characters contain a NUL character. +- The patch text is larger than 1 MiB (`DIFF_PATCH_MAX_BYTES`). +- A pure rename has no hunk. +- The update hunks from Codex are malformed. In a hunk, a line that starts with `\` is valid only as the exact `\ No newline at end of file` marker. + +For an update, the fallback reads the file and applies the Codex hunks. +When the adapter cannot parse or apply the hunks, it omits the block and logs the change. + +### Receiver validation + +A receiver accepts the patch only after the negotiation. +It validates both versions, the format, and the patch text. +If validation fails, the receiver ignores `diffPatch` and reads the standard text fields. +Unknown fields do not make a valid payload invalid. + +### Codex behavior + +Codex App Server supplies compact hunks for an update and the file content for an addition or a deletion. +The adapter checks the update hunks and puts its own Git headers before them. +It drops the file headers that Codex supplied, so that all headers name the same paths. +It builds one full-file patch for an addition or a deletion from the content that Codex supplied. +The adapter applies this mode to live file changes and to replayed session history. +It does not read the current file when it can forward a provider patch. + +## Permission presentation + +Permission decisions use the standard ACP `session/request_permission` method. +The optional `_meta.jetbrains.air.permission` record adds display text only. +It never changes which actions a client may approve. +Only AIR gets the record. It needs no AIR capability. + +### Request + +Every permission request contains: + +- a `toolCall` that describes the action to approve; +- an ordered `options` array with every decision that the user may select; +- an optional request-level and option-level `_meta.jetbrains.air.permission` record. + +```json +{ + "sessionId": "session-1", + "toolCall": { + "toolCallId": "command-7", + "kind": "execute", + "status": "pending", + "title": "Run command", + "rawInput": { "command": "npm test", "cwd": "/workspace" } + }, + "options": [ + { "optionId": "allow_once", "name": "Yes, proceed", "kind": "allow_once" }, + { "optionId": "cancel", "name": "No, and tell Codex what to do differently", "kind": "reject_once" } + ], + "_meta": { + "jetbrains": { + "air": { + "version": 1, + "permission": { + "version": 1, + "title": "Run command?", + "description": "The test suite needs to run outside the current sandbox." + } + } + } + } +} +``` + +The client makes a decision by returning one of the advertised `optionId` values. +It must not derive a decision from the option label, the `kind`, or the metadata. +The adapter keeps the exact Codex decision of each option and returns that value to Codex. + +### Presentation record + +| Field | Level | Required | Meaning | +| --- | --- | --- | --- | +| `version` | request, option | yes | Must equal `1`. | +| `title` | request | yes | The approval heading. | +| `description` | request | no | The non-blank reason that Codex supplied. | +| `description` | option | yes | What the option does. Only MCP elicitation options carry it. | + +The request titles are `Run command?`, `Allow network access?`, `Make edits?`, and `Grant permissions?`. +The adapter does not copy action payloads into the metadata. + +### Tool call of the request + +The `toolCall` is an ACP `ToolCallUpdate`. The client merges it into the stored tool call. + +- The request always carries `toolCallId`, `title`, and `rawInput`. +- `rawInput` holds the structured command, working directory, URL, or permission profile. +- `locations` holds the affected paths when Codex supplies them. +- `content` holds details that do not fit a location, such as a network host, a filesystem glob, or a special Codex scope. +- When the client already has the command or file-change tool call, the request omits `status` and `kind`. + The title is then the title that the tool call already shows. + The request does not reset a started tool call to `pending`. +- A network approval has its network title. +- An approval of an MCP tool call that already started carries only `toolCallId` and `status: pending`. +- The question of a standalone MCP elicitation is in `rawInput.description`. + A client without `rawInputRendering` also gets it as text in `content`. + +A client that is not AIR gets the request tool call of the adapter before the AIR extensions. +It always carries `kind` and `status: pending`, and a generic title such as `Run command`, `Edit files`, or a network title. +Its `locations` hold every path of the Codex command actions. + +Command approvals use `kind: execute`. File changes use `kind: edit`. +Additional sandbox permissions use `kind: other`. A URL authorization fallback uses `kind: fetch`. +For a file change, the locations come from the matching Codex `fileChange` item. +The adapter does not present `grantRoot` as though every file below it changes. + +### Command and network decisions + +When Codex sends `availableDecisions`, that ordered list is authoritative. +An older Codex version that omits the list gets the native Codex fallback decision set. + +| Codex decision | ACP option kind | Meaning | +| --- | --- | --- | +| `accept` | `allow_once` | Approve this execution once. | +| `acceptForSession` | `allow_always` | Approve the command, host, or requested permissions for this session. | +| `acceptWithExecpolicyAmendment` | `allow_always` | Approve and install the exact proposed command-prefix rule. | +| network amendment with `allow` | `allow_always` | Approve and install the exact proposed allow rule. | +| network amendment with `deny` | `reject_always` | Reject and install the exact proposed deny rule. | +| `decline` | `reject_once` | Reject this execution and continue the turn. | +| `cancel` | `reject_once` | Reject this execution and stop the pending operation. | + +The adapter returns an exec-policy or network amendment as the exact structured value that Codex supplied. +It rejects an amendment that does not match the proposal. +It hides an exec-policy option whose prefix contains a line break, as the native Codex UI does. +An unknown, malformed, empty, or inconsistent decision set fails closed with `cancel`. +The adapter does not invent replacement choices. + +### File changes + +| ACP option | Kind | Codex decision | +| --- | --- | --- | +| `Yes, proceed` | `allow_once` | `accept` | +| `Yes, and don't ask again for these files` | `allow_always` | `acceptForSession` | +| `No, and tell Codex what to do differently` | `reject_once` | `cancel` | + +The protocol enum also contains `decline`. +The native Codex file-change prompt does not offer it, so the adapter does not offer it. + +### Additional sandbox permissions + +Codex can request a structured network and filesystem permission profile. +The adapter returns only permissions from that requested profile. +Codex intersects the response with the original request. + +| User choice | Scope | `strictAutoReview` | +| --- | --- | --- | +| Grant for this turn | `turn` | `false` | +| Grant for this turn with strict auto review | `turn` | `true` | +| Grant for this session | `session` | `false` | +| Continue without permissions | `turn` | `false` | + +Strict auto review is turn-scoped on purpose. +It sends the later actions of that turn through Codex review, also when the sandbox policy allows them. +The adapter never combines it with a session-scoped grant. +Cancellation, an unknown option, a stale turn, or a missing handler returns an empty profile with turn scope and `strictAutoReview: false`. + +### MCP elicitation approvals + +A message-only MCP elicitation uses `session/request_permission`. +The client then gets the same decision matrix as the native Codex UI. +Codex offers durable choices through the request `_meta.persist`. +The adapter never creates a persistence scope that the server did not offer. + +| Condition | ACP option | MCP response | +| --- | --- | --- | +| always | `Allow` | `action: accept` | +| `persist` contains `session` | `Allow for this session` | `action: accept`, `_meta.persist: session` | +| `persist` contains `always` | `Always allow` | `action: accept`, `_meta.persist: always` | +| request is not a tool approval | `Deny` | `action: decline` | +| always | `Cancel` | `action: cancel` | + +A tool-call approval has no `Deny` choice. Cancellation stops the tool call. +For an ordinary MCP request, `Deny` declines the request and the turn continues. `Cancel` stops the request. +A tool-call approval carries `_meta.is_mcp_tool_approval: true`. + +A structured form or URL elicitation uses the ACP elicitation capability when the client declares it. +The adapter cancels a structured form that the client cannot render. +A permission fallback would lose required input. +A message-only or URL request can use the permission fallback, because no field values are lost. + +The Codex app-server omits the MCP request identity from form-mode elicitation parameters. +The adapter links the request to an MCP tool call only when exactly one pending call for that thread and server exists. +An ambiguous request gets a unique standalone `toolCallId` and includes the full message and schema. + +### Lifecycle and safety + +A permission prompt belongs to the active Codex turn. +The adapter rejects a request for a stale or interrupted turn without opening client UI. +Cancellation, an unadvertised `optionId`, a transport failure, and a malformed response all fail closed. +The adapter does not rebuild provider effects from ACP `kind` values. +`allow_always` describes presentation intent. It does not create a policy rule. +Only the exact Codex decision of the selected `optionId` can do that. + +The active permission surface is the app-server v2 request methods: + +- `item/commandExecution/requestApproval` +- `item/fileChange/requestApproval` +- `item/permissions/requestApproval` +- `mcpServer/elicitation/request` + +The deprecated `execCommandApproval` and `applyPatchApproval` methods are not a second permission pipeline. + +## Plan content delta and plan review + +### Plan stream + +AIR accepts a plan in one of two modes: streamed text, or a path to a file that AIR follows. An agent that writes +its plan to a file sends the path. Codex keeps the plan only as text, so this adapter always uses the streamed mode. +It does not declare the AIR `planFile` capability. + +Codex writes a Markdown plan in plan mode. The adapter streams it: + +- A client that declares the draft `clientCapabilities.plan` gets `plan_update` with `plan = {type: "markdown", planId, content}`. + The adapter throttles these updates to one per 150 ms. +- With `planContentDelta`, the first report of a plan carries the whole text. + Each later report carries `plan.content: ""` and the appended text in `_meta.jetbrains.air.contentDelta`. + The client appends that text to the plan content. +- Without `planContentDelta`, each report is a full `plan_update` snapshot. +- AIR without plan updates gets the plan as `agent_message_chunk` text with `phase: final_answer`. +- Another client without plan updates gets the whole plan in one `agent_message_chunk` when the plan item completes. + +The completed plan item is authoritative. The stream sends only what the client does not have yet. +When a completed plan differs from the streamed text, the adapter sends a snapshot. + +```json +{ + "sessionUpdate": "plan_update", + "plan": { "type": "markdown", "planId": "item-1", "content": "" }, + "_meta": { "jetbrains": { "air": { "version": 1, "contentDelta": "\n3. Run the tests." } } } +} +``` + +### Plan review + +After a completed plan, the adapter asks the user whether to implement it. +Every client gets the same request: + +- `toolCallId: plan-review:`, `kind: switch_mode`, `title: "Implement this plan?"`. +- The plan text in `toolCall.rawInput.plan`. AIR reads the plan of the review there. +- Options `implement_plan` (`allow_once`) and `revise_plan` (`reject_once`). +- No `_meta`. + +The final update of that tool call puts the decision text in `rawOutput`. + +## Goal + +The goal extension exposes a long-running, session-scoped objective. +It is shaped like a possible future first-class ACP API. +The adapter sends no other goal key. +Only AIR gets the goal capability and the goal snapshots. +Another client gets no goal key and no `session_info_update` for a goal. + +### Capability + +The `initialize` response advertises the goal support: + +```json +{ "version": 1, "controlMethod": "_session/goal", "actions": ["set", "pause", "resume", "clear"] } +``` + +`actions` is the subset of `set`, `pause`, `resume`, and `clear` that the adapter supports. +A client must not assume support for an action that is not advertised. + +### Control request + +The client sends `_session/goal` with `sessionId` and `action`. +`set` also requires a non-blank `objective`. +`/goal` stays the user-facing way to set, pause, resume, or clear a goal. +The adapter still accepts `_codex/session/goal_control` as a legacy alias. It does not advertise the alias. + +### Session state + +The adapter publishes the current snapshot in `session_info_update._meta.jetbrains.air.goal`. +Clearing a goal publishes `goal: null`. + +```json +{ + "objective": "Ship the change", + "status": "active", + "createdAt": 1710000000000, + "updatedAt": 1710000012000, + "tokenBudget": null, + "tokensUsed": 42, + "timeUsedSeconds": 12, + "controlMethod": "_session/goal" +} +``` + +The statuses are `active`, `paused`, `blocked`, `limited`, and `complete`. +Timestamps are Unix milliseconds. + +### Lifecycle + +A goal belongs to the ACP session, not to one `session/prompt` request. +Goal activity and prompt activity are independent: + +- `status: active` means that the objective can drive more work. It does not mean that a prompt runs now. +- A prompt completes when its backend turn reaches a quiet boundary, also when the goal stays active. +- A later autonomous cycle can publish more session updates outside that completed prompt. +- While a turn runs, a client uses steering or prompt queueing when advertised. + While the session is quiet, a client can send an ordinary `session/prompt`. + +This separation keeps a goal from holding the prompt slot of the session. +A client can show "working now" apart from "objective still active". + +### Codex mapping + +Codex `thread/goal/*` notifications map into the neutral snapshot. +The provider statuses `usageLimited` and `budgetLimited` map to `limited`. +The adapter converts the Codex timestamps from seconds to milliseconds. +It skips an update that does not change the snapshot. + +## Recommended config values + +The `recommendedValue` extension lets a client show the Codex recommendation apart from the current selection. +It applies to the `model` and `reasoning_effort` config selectors. + +When the client declares `recommendedValue`, a selector with a recommendation carries: + +```json +{ "_meta": { "jetbrains": { "air": { "version": 1, "recommendedValue": "medium" } } } } +``` + +- The recommended model is the available model that Codex marks `isDefault`. +- The recommended effort is the `defaultReasoningEffort` of the current model. +- The adapter sends a value only when the selector offers it as an option. +- `recommendedValue` is independent of `currentValue`. An explicit user choice stays current. +- After a model switch, the adapter computes the effort recommendation again for the new model. + +Without the capability, the config options keep their old shape and carry no recommendation. + +## Async tasks + +Codex app-server owns shell commands that keep running after their tool call. +The adapter exposes them as async tasks when the client declares `asyncTasks`. +Without the capability, the adapter sends no async task update. + +### Lifecycle + +- The adapter reads the active processes from `thread/backgroundTerminals/list`. +- Before the spawn update, it marks the command tool call with `_meta.jetbrains.air.asyncTasks.backgrounded: true`. + AIR then keeps the command card active without a second copy of its output. +- It sends `async_task_spawned` with `taskType: "shell"`, `showInTranscript: false`, `canStop: true`, and `toolCallId`. + The name is the command title. The existing command card owns the output. +- For a root command, the command item id is both the async task id and the tool call id. +- A child command prefixes its task id with the child thread id, so task ids stay distinct across native subagent sessions. + The tool call id stays the command item id. The adapter publishes the task on the child session. +- When the command ends, the adapter sends `async_task_state_update` with `completed` or `failed`. +- The active-terminal list repairs a lost completion event. + The adapter reports `stopped` when an announced terminal leaves that list. +- Session loading restores root and child tasks after it replays their command history. +- A provider restart stops the old tasks and moves task control to the new app-server client. +- When the app-server exits, the adapter reports each unfinished task as `failed`. + +The app-server process id stays an internal control handle. + +### Stop request + +The client sends `_session/async_task/stop`: + +```json +{ "sessionId": "thread-id", "asyncTaskId": "command-item-id" } +``` + +The adapter resolves the process id and calls `thread/backgroundTerminals/terminate`. +It returns `{ "stopped": true }` after app-server accepts the termination. + +## Agent file-change report + +Standard ACP describes a change from one tool call. It has no complete file list for one prompt turn. +The version 1 `agentFileChangeReport` extension adds that list. + +### Request + +The client declares `agentFileChangeReport` and adds this object to `session/prompt`: + +```json +{ "_meta": { "jetbrains": { "air": { "agentFileChangeReportRequest": { "version": 1, "requestId": "a-unique-request-id" } } } } } +``` + +The request object must have exactly the keys `version` and `requestId`, and `version` must be `1`. +The request id has 1 to 128 characters: ASCII letters, digits, `.`, `_`, `:`, and `-`. +The adapter ignores a malformed request. + +### Report + +The adapter sends one `session_info_update` before the `PromptResponse`: + +```json +{ + "sessionUpdate": "session_info_update", + "_meta": { + "jetbrains": { + "air": { + "version": 1, + "agentFileChangeReport": { + "version": 1, + "requestId": "a-unique-request-id", + "status": "reported", + "paths": ["/workspace/src/App.ts"], + "declaredComplete": false, + "truncated": false, + "uncertainty": "Codex turn diffs may omit same-content renames and changes made outside apply_patch, including shell commands, version-control commands, generators, and child processes." + } + } + } + } +} +``` + +- Each path is an absolute normalized path in the working directory or in an additional workspace directory. +- The report has no file content, diff, line count, or path order guarantee. +- The adapter sends at most 1,024 paths. Each path has at most 4,096 characters. +- The serialized report has at most 256 KiB. The optional `uncertainty` has at most 2,000 characters. +- The adapter rejects a turn diff larger than 8 MiB before it parses it. + +### Codex source + +The adapter derives the report from the final aggregated `turn/diff/updated` snapshot of the main turn. +It does not run an extra model turn. +It keeps the Codex `cwd_relative_turn_diffs` feature disabled, so paths are relative to the Git root. +The Codex snapshot tracks `apply_patch` changes. +It can omit same-content renames and changes from shell commands, version-control commands, generators, or child processes. +The adapter therefore sends `declaredComplete: false` and explains the limit in `uncertainty`. + +### Unavailable report + +The adapter sends `status: "unavailable"` with a `reason`: + +| Reason | Cause | +| --- | --- | +| `cancelled` | The prompt was cancelled. | +| `invalidOutput` | The turn diff is invalid. | +| `notReported` | No provider turn ran. | +| `providerError` | The provider failed. | + +The `timeout` reason stays in the version 1 wire contract for backward compatibility. This adapter does not produce it. +A report failure does not change the prompt outcome. +A failure of the main turn still follows the normal prompt error behavior. + +The client must match the request id. +It must ignore a duplicate, stale, malformed, or unavailable report. +Rollback is outside this extension. The adapter advertises no `undo` or `rollback` command. + +## Session failure + +The `sessionFailure` extension sends warnings and errors as durable transcript entries. +The client shows them in order beside user, agent, and tool messages. +They are not assistant text and not temporary banners. + +### Activation + +The extension is active when the client declares `sessionFailure`. +Without it, the adapter keeps the legacy behavior: +JSON-RPC errors, `Warning:` and `Config warning:` text chunks, and `session_info_update._meta.codex.error`. + +A client can also declare the ACP `clientCapabilities.session.notices`. +Then a `warning`, `configWarning`, or `deprecationNotice` notification goes out as an ACP `notice` session update. +It does not go out as a `sessionFailure` record. Errors still use `sessionFailure`. + +### Record + +```json +{ + "_meta": { + "jetbrains": { + "air": { + "version": 1, + "sessionFailure": { + "id": "turn-7:error", + "revision": 1, + "category": "limit", + "severity": "error", + "title": "You've hit your usage limit.", + "actions": [] + } + } + } + } +} +``` + +| Field | Required | Type | Meaning | +| --- | --- | --- | --- | +| `id` | yes | non-empty string | Stable identity of one incident. | +| `revision` | yes | positive integer | Increasing version of that incident. | +| `category` | yes | category | Broad visual group. | +| `severity` | yes | `warning` or `error` | Inline warning or error presentation. | +| `title` | yes | string | The complete user-facing text. | +| `details` | no | string | Long text that does not fit in `title`. | +| `actions` | yes | ordered string array | Recovery actions that the adapter recommends. | + +### Identity and revisions + +- The first record of an incident creates one transcript entry at the current stream position. +- The same `id` with a higher `revision` updates that entry in place. +- The client ignores the same or a lower revision. +- A later, independent incident gets a new `id`. +- A turn failure uses `:error`. A later incident in the same scope uses `:error::`. +- A notice uses `:notice::`. Consecutive equal notices reuse the id with a higher revision. + +### Delivery + +- A terminal failure of the running turn goes on the `PromptResponse._meta` with `stopReason: end_turn`. + The response keeps `_meta.quota` next to it. +- A retry warning, a failure of another turn, a failure after the prompt ended, and a notice go in a `session_info_update`. +- A warning does not end a turn. + +### Categories and actions + +| Codex condition | Category | Actions | +| --- | --- | --- | +| `httpConnectionFailed`, `responseStreamConnectionFailed`, `responseStreamDisconnected`, `responseTooManyFailedAttempts`, app-server exit | `connection` | `retry`, `new_session` | +| `unauthorized`, HTTP 401 | `access` | `login` | +| `rateLimitExceeded`, HTTP 429 | `limit` | `retry` | +| `usageLimitExceeded` | `limit` | none | +| `contextWindowExceeded`, `sessionBudgetExceeded` | `limit` | `new_session` | +| `cyberPolicy`, `misalignmentPolicyViolation`, `badRequest` | `request` | none | +| `serverOverloaded` | `service` | `retry` | +| `internalServerError`, an unexpected adapter error | `service` | `retry`, `new_session` | +| `threadRollbackFailed`, `sandboxError`, `activeTurnNotSteerable`, `other`, unknown | `service` | `retry` | +| `warning`, `configWarning`, `deprecationNotice` notifications | `unknown` | none | + +A retry warning (`willRetry: true`) has `severity: warning` and no actions. +A notice has `severity: warning`. +A `deprecationNotice` is shown only to a client with the capability. Other clients never saw it. + +The actions of version 1 are `retry`, `login`, and `new_session`. +The client filters the actions that it cannot run and ignores unknown or duplicate values. +The client must not infer actions from the category. + +### Title and details + +The title is the Codex error or warning message. +An app-server exit uses `Connection to Codex was lost.` +An unexpected adapter error uses `Codex encountered an internal error.` +A notice with details puts `summary — details` in the title when that fits in 240 characters. +Otherwise the summary goes to `title` and the details go to `details`. + +### Recovery + +Recovery is internal adapter state and is not sent. +A retry warning stops being active when Codex produces turn content again. +A successful turn ends an active warning of that turn. +Recovery never removes the transcript record. + +## Native subagent sessions + +The adapter implements the draft [ACP subagent RFD](https://github.com/agentclientprotocol/agent-client-protocol/pull/1992). +[Subagent sessions](subagent-sessions.md) describes the lifecycle. +This section covers only the AIR bridge. + +- The canonical client field is `clientCapabilities.subagents: {}`. +- Released ACP SDKs can strip that draft field. + AIR can instead declare `nativeSubagentSessions` in `_meta.jetbrains.air.capabilities`. +- Either signal enables native subagent sessions. New clients must prefer the canonical field. +- The agent always advertises `agentCapabilities.sessionCapabilities.subagents`. It advertises `nativeSubagentSessions` to AIR. +- Without either signal, a Codex subagent stays an ordinary tool call. + AIR gets `_meta.jetbrains.air.subagent: true` on a spawn. Another client gets the tool call without `_meta`. + +## Context compaction + +The adapter implements the ACP session compaction RFD. See [Session compaction](session-compaction.md). +A client that does not declare `session.compaction` gets a synthetic tool call instead: + +- `toolCallId` is the Codex item id, `title: "Compact conversation"`, `kind: think`. +- For AIR, each report carries `_meta.jetbrains.air.contextCompaction = {version: 1}`. + The standard `toolCallId` and `status` own the identity and the phase. + Another client gets the tool call without `_meta`. +- Codex supplies no trigger, token counts, or duration, so the record has only `version`. + +## Session fork point + +AIR can fork a session at one agent message. +It adds this object to the `session/fork` request: + +```json +{ "_meta": { "jetbrains": { "air": { "fork": { "version": 1, "messageId": "item-12", "messageFingerprint": "sha256:<64 hex>", "messageOccurrence": 1 } } } } } +``` + +- The adapter reads the object only when `version` is `1`. +- `messageId` must be a non-empty string. + An id with a `:segment:` suffix also matches the message without the suffix. +- `messageFingerprint` is optional. It is `sha256:` and the SHA-256 hex digest of the message text. + The adapter uses it when no item has the id. +- `messageOccurrence` is optional, a positive integer, and `1` by default. + It selects among agent messages with the same fingerprint. +- An invalid field fails the request with `invalidParams`. +- When no message matches, the request fails with `invalidParams`. +- The fork keeps the history up to the turn that holds the message. + +## Presentation hints + +The adapter sends these keys only to AIR: + +- `agent_message_chunk._meta.jetbrains.air.phase` carries the Codex phase of the message, for example `final_answer`. +- Each session mode and each value of the `mode` config option carries `_meta.jetbrains.air.kind`. + `read-only` is `standard`, `agent` is `auto_review`, and `agent-full-access` is `full_access`. +- An available command can carry `_meta.jetbrains.air.commandAction`: + - `/plan` has `{kind: "setConfigOption", configId, value, resetValue, presentation: "state"}`. It switches the collaboration mode to plan. + - `/goal` has `{kind: "prefixPrompt", presentation: "state"}`. + +## Removed keys + +These keys moved into the AIR namespace. +AIR gets only the new key. A client that is not AIR gets neither the old key nor the new key. + +| Old key | New key | +| --- | --- | +| `agent_message_chunk._meta.codex.phase` | `_meta.jetbrains.air.phase`, same values | +| `initialize._meta.goal`, `session_info_update._meta.goal` | `_meta.jetbrains.air.goal`, same shape | +| mode `_meta.kind`, config option value `_meta.kind` | `_meta.jetbrains.air.kind` | +| available command `_meta.commandAction` | `_meta.jetbrains.air.commandAction` | +| tool call `_meta.contextCompaction` | `_meta.jetbrains.air.contextCompaction` | + +The adapter does not send these keys to any client: + +- `_meta.codex.subagent` and `_meta.codex.collaboration`; +- the plan review `_meta.codex.kind` and `_meta.codex.planItemId`; +- the permission `_meta.permission`, replaced by `_meta.jetbrains.air.permission`; +- the diff `_meta.jetbrains.air.diffStats`. diff --git a/docs/async-tasks.md b/docs/async-tasks.md deleted file mode 100644 index 88f0e98a..00000000 --- a/docs/async-tasks.md +++ /dev/null @@ -1,42 +0,0 @@ -# Background terminal tasks - -Codex app-server owns shell commands that continue after their initial tool call. The adapter exposes these commands through the AIR async task extension. - -## Negotiation - -The client adds `asyncTasks` to `_meta.jetbrains.air.capabilities`. The adapter advertises the same capability in its `initialize` response. - -The adapter emits no async task updates when the client does not advertise this capability. - -## Lifecycle - -The adapter uses `thread/backgroundTerminals/list` as the source of active processes. It maps each active process to `async_task_spawned`. - -Before the spawn update, the adapter marks the command with `_meta.jetbrains.air.asyncTasks.backgrounded`. AIR can then keep the command card active without duplicating its output. - -For a root command, the command item ID is both the async task ID and the related tool call ID. A child command prefixes its task ID with the child thread ID. The prefix keeps task IDs distinct across native subagent sessions. The related tool call ID remains the command item ID. - -The adapter publishes a child command on its native subagent session. The app-server process ID remains an internal control handle. - -The existing command card owns the command output. Therefore, a background terminal task sets `showInTranscript` to `false`. - -When the command ends, the adapter emits `async_task_state_update` with `completed` or `failed`. - -The active-terminal list repairs a lost completion event. The adapter reports `stopped` when an announced terminal disappears from that list. - -Session loading restores root and child tasks after it replays their command history. A provider restart stops old tasks and moves task control to the new app-server client. - -If the app-server exits, the adapter reports each unfinished task as `failed`. - -## Stop request - -The client sends `_session/async_task/stop` with the ACP session ID and async task ID: - -```json -{ - "sessionId": "thread-id", - "asyncTaskId": "command-item-id" -} -``` - -The adapter resolves the app-server process ID and calls `thread/backgroundTerminals/terminate`. It returns `{ "stopped": true }` after app-server accepts the termination. diff --git a/docs/diff-patch-extension.md b/docs/diff-patch-extension.md deleted file mode 100644 index 97f41799..00000000 --- a/docs/diff-patch-extension.md +++ /dev/null @@ -1,124 +0,0 @@ -# AIR diff patch extension - -Status: Experimental - -This extension lets an ACP agent send one compact Git patch instead of file text snapshots. -It applies to an ACP `diff` content block. - -## Capability negotiation - -The client advertises `diffPatch` in the initialize request: - -```json -{ - "clientCapabilities": { - "_meta": { - "jetbrains": { - "air": { - "version": 1, - "capabilities": ["diffPatch"] - } - } - } - } -} -``` - -The adapter advertises the same capability in the initialize response: - -```json -{ - "_meta": { - "jetbrains": { - "air": { - "version": 1, - "capabilities": ["diffPatch"] - } - } - } -} -``` - -The adapter uses patch mode only when both peers advertise `diffPatch` with an integer AIR envelope version of at least 1. -If either declaration is absent or malformed, the adapter sends the standard `oldText` and `newText` values. - -## Diff content - -Patch mode puts the payload at `_meta.jetbrains.air.diffPatch`: - -```json -{ - "type": "diff", - "path": "/workspace/src/App.ts", - "oldText": null, - "newText": "", - "_meta": { - "kind": "update", - "jetbrains": { - "air": { - "version": 1, - "diffPatch": { - "version": 1, - "format": "git_patch", - "text": "diff --git a/workspace/src/App.ts b/workspace/src/App.ts\n--- a/workspace/src/App.ts\n+++ b/workspace/src/App.ts\n@@ -1 +1 @@\n-old\n+new\n" - } - } - } - } -} -``` - -| Field | Type | Meaning | -| --- | --- | --- | -| `version` | integer | Must equal `1`. | -| `format` | string | Must equal `git_patch`. | -| `text` | string | One unified Git patch for the block's file. | - -The patch contains Git file headers and at least one `@@` hunk. -Each header path is the absolute file path without its leading slash, with the `a/` or `b/` prefix. -A Windows path uses forward slashes, for example `a/C:/work/App.ts`. -The adapter quotes a path in C style when it contains a double quote, a backslash or a control character, as Git does. -It does not quote non-ASCII characters. -A `---` or `+++` line ends with a tab when its unquoted path contains a space. - -An added file has a `new file mode 100644` header and uses `/dev/null` as the old file header. -A deleted file has a `deleted file mode 100644` header and uses `/dev/null` as the new file header. -A moved file has `rename from` and `rename to` headers, and the block `path` is the target path. -The patch keeps the provider bytes, including a carriage return. -A file without a final newline ends with the `\ No newline at end of file` marker. - -In patch mode, `oldText: null` and `newText: ""` are compatibility placeholders. -They are not file snapshots or changed fragments. -The receiver must use `diffPatch.text` as the change payload after it accepts the negotiated extension. - -The receiver derives line counts and changed fragments from the patch. - -## Compatibility and fallback - -The adapter sends the standard ACP diff when it cannot build a valid patch. -That fallback contains meaningful `oldText` and `newText` values and omits `diffPatch`. -The adapter uses the fallback in these cases: - -- The file is empty, so no hunk can express it. -- The content is binary. The adapter treats content as binary when its first 8000 characters contain a NUL character. -- The patch text is larger than 1 MiB (`DIFF_PATCH_MAX_BYTES`). -- A pure rename has no hunk. -- The update hunks from Codex are malformed. In a hunk, a line that starts with `\` is valid only as the exact `\ No newline at end of file` marker. - -For an update, the fallback reads the file and applies the Codex hunks. -When the adapter cannot parse the hunks or apply them, it omits the block and logs the change. - -A receiver accepts the patch only after bilateral negotiation. -It also validates both versions, the format, and the patch text. -If validation fails, the receiver ignores `diffPatch` and reads the standard text fields. -Unknown fields do not invalidate a valid payload. - -## Codex behavior - -Codex App Server supplies compact hunks for updates and file content for additions and deletions. -The adapter checks the update hunks and puts its own Git headers before them. -It drops the file headers that Codex supplied, so that all headers name the same paths. -It builds one full-file patch for an addition or deletion because the provider already supplied that content. - -The adapter applies this mode to live file changes and replayed session history. -It does not read the current file when it can forward a provider patch. diff --git a/docs/goal-extension.md b/docs/goal-extension.md deleted file mode 100644 index 4ab9c2ac..00000000 --- a/docs/goal-extension.md +++ /dev/null @@ -1,57 +0,0 @@ -# Goal extension - -This document defines a provider-neutral experimental ACP extension implemented by `codex-acp`. It is intentionally shaped like a possible future first-class ACP API: implementations publish `_meta.goal`, not provider-specific metadata such as `_meta.codex.goal`. - -## Capability negotiation - -An agent advertises support in its `initialize` response: - -```json -{ - "_meta": { - "goal": { - "version": 1, - "controlMethod": "_session/goal", - "actions": ["set", "pause", "resume", "clear"] - } - } -} -``` - -`actions` is the implementation-supported subset of `set`, `pause`, `resume`, and `clear`. Clients must not infer support for an action that is not advertised. The control request contains `sessionId` and `action`; `set` additionally requires a non-blank `objective`. - -## Session state - -The current snapshot is published in `session_info_update._meta.goal`. Clearing a goal publishes `goal: null`. - -```json -{ - "objective": "Ship the change", - "status": "active", - "createdAt": 1710000000000, - "updatedAt": 1710000012000, - "tokenBudget": null, - "tokensUsed": 42, - "timeUsedSeconds": 12, - "controlMethod": "_session/goal" -} -``` - -Common statuses are `active`, `paused`, `blocked`, `limited`, and `complete`. Optional fields allow implementations to report budgets, usage, iteration count, and the last continuation reason. Timestamps are Unix milliseconds. - -## Lifecycle architecture - -A goal belongs to the ACP session, not to an individual `session/prompt` request. Goal activity and prompt activity are independent: - -- `status: active` means the persistent objective can drive more work; it does not mean an ACP prompt is currently executing. -- A prompt completes when its current backend turn reaches a quiescent boundary, even when the goal remains active. -- A later autonomous cycle may publish more session updates outside that completed prompt. -- While a turn is running, clients use steering or prompt queueing when advertised. While the session is quiescent, clients may send an ordinary `session/prompt`. - -This separation prevents a persistent goal from monopolizing the session's prompt slot and lets clients model “working now” independently from “objective remains active.” - -## Codex mapping and compatibility - -Codex `thread/goal/*` notifications map into the neutral snapshot. Provider statuses `usageLimited` and `budgetLimited` map to `limited`; Codex second-based timestamps are converted to Unix milliseconds. `/goal` remains the user-facing way to set, pause, resume, or clear a goal. - -`_codex/session/goal_control` remains accepted as a legacy alias, but new clients discover and use `_session/goal`. The alias is not advertised and no provider-specific goal metadata is emitted. diff --git a/docs/permission-extension.md b/docs/permission-extension.md deleted file mode 100644 index f23f3a04..00000000 --- a/docs/permission-extension.md +++ /dev/null @@ -1,180 +0,0 @@ -# Permission presentation extension - -For a user-facing summary of behavior changes, see -[`permission-changes.ru.md`](permission-changes.ru.md). - -This document defines the provider-neutral permission presentation implemented by `codex-acp`. Permission decisions use the standard ACP `session/request_permission` method. The optional `_meta.permission` extension adds display text only; it never changes which actions a client may approve. - -## Protocol contract - -Every permission request contains: - -- a `toolCall` describing the action that needs approval; -- an ordered `options` array containing every decision the user may select; -- optional request-level and option-level `_meta.permission` presentation data. - -Clients make a decision by returning one of the advertised `optionId` values. They must not derive a decision from the option label, `kind`, or metadata. `codex-acp` keeps the exact Codex decision associated with each option and returns that original value to Codex. - -```json -{ - "sessionId": "session-1", - "toolCall": { - "toolCallId": "command-7", - "kind": "execute", - "status": "pending", - "title": "Run command", - "rawInput": { - "command": "npm test", - "cwd": "/workspace" - } - }, - "options": [ - { - "optionId": "allow_once", - "name": "Yes, proceed", - "kind": "allow_once" - }, - { - "optionId": "cancel", - "name": "No, and tell Codex what to do differently", - "kind": "reject_once" - } - ], - "_meta": { - "permission": { - "version": 1, - "title": "Run command?", - "description": "The test suite needs to run outside the current sandbox." - } - } -} -``` - -The standard ACP fields are the compatibility contract. A client that ignores `_meta.permission` can still render the action, present every option, and return a correct decision. - -## Presentation metadata - -Request-level metadata has this shape: - -```json -{ - "_meta": { - "permission": { - "version": 1, - "title": "Allow network access?", - "description": "Download the requested dependency." - } - } -} -``` - -`version` and `title` are required. `description` is optional and contains the non-blank reason supplied by Codex. Action payloads are not copied into metadata. - -An individual option may provide a description: - -```json -{ - "optionId": "allow_session", - "name": "Allow for this session", - "kind": "allow_always", - "_meta": { - "permission": { - "version": 1, - "description": "Run the tool and remember this choice for this session." - } - } -} -``` - -No capability negotiation is required. The metadata is optional, additive, and safe for clients to ignore. - -## Action presentation - -The `toolCall` remains the authoritative description of the action: - -- `rawInput` contains structured command, working-directory, server, URL, or permission-profile data. -- `locations` contains affected filesystem paths when Codex provides them. -- `content` carries details that do not fit a location, such as a network host, filesystem glob, special Codex scope, or MCP message. -- `title`, `kind`, and `status` provide the standard ACP summary. - -Command approvals use `kind: execute`. File changes use `kind: edit`. Additional sandbox permissions use `kind: other`. URL authorization fallback uses `kind: fetch`. - -For file changes, locations come from the correlated Codex `fileChange` item. `grantRoot` is not presented as though every file below it will be modified. - -## Command and network decisions - -When Codex sends `availableDecisions`, that ordered list is authoritative. Older Codex versions that omit it use the native Codex fallback decision set. - -| Codex decision | ACP option kind | Meaning | -| --- | --- | --- | -| `accept` | `allow_once` | Approve this execution once. | -| `acceptForSession` | `allow_always` | Approve the command, host, or requested permissions for this session. | -| `acceptWithExecpolicyAmendment` | `allow_always` | Approve and install the exact proposed command-prefix rule. | -| network amendment with `allow` | `allow_always` | Approve and install the exact proposed allow rule. | -| network amendment with `deny` | `reject_always` | Reject and install the exact proposed deny rule. | -| `decline` | `reject_once` | Reject this execution and continue the turn. | -| `cancel` | `reject_once` | Reject this execution and abort the pending operation. | - -Exec-policy and network amendments are returned as the exact structured values supplied by Codex. An amendment is rejected if it does not match the corresponding proposal. An exec-policy option whose rendered prefix contains a line break is not shown, matching the native Codex UI. - -Unknown, malformed, empty, or internally inconsistent authoritative decision sets fail closed with `cancel`; the adapter does not invent replacement choices. - -## File changes - -File-change approvals expose the native Codex choices: - -| ACP option | Kind | Codex decision | -| --- | --- | --- | -| `Yes, proceed` | `allow_once` | `accept` | -| `Yes, and don't ask again for these files` | `allow_always` | `acceptForSession` | -| `No, and tell Codex what to do differently` | `reject_once` | `cancel` | - -Although the protocol decision enum also contains `decline`, the native Codex file-change prompt does not currently advertise it. - -## Additional sandbox permissions - -Codex may request a structured network and filesystem permission profile. `codex-acp` returns only permissions from that requested profile; Codex intersects the response with the original request before applying it. - -| User choice | Scope | `strictAutoReview` | -| --- | --- | --- | -| Grant for this turn | `turn` | `false` | -| Grant for this turn with strict auto review | `turn` | `true` | -| Grant for this session | `session` | `false` | -| Continue without permissions | `turn` | `false` | - -Strict auto review is intentionally turn-scoped. It causes subsequent actions in that turn to pass through Codex review even when ordinary sandbox policy would allow them. It is never combined with a session-scoped grant. - -Cancellation, an unknown option, a stale turn, or a missing handler returns an empty permission profile with turn scope and `strictAutoReview: false`. - -## MCP elicitation approvals - -Message-only MCP elicitations use `session/request_permission` so clients receive the same decision matrix as the native Codex UI. Codex advertises durable choices through request `_meta.persist`; `codex-acp` never creates a persistence scope that the server did not offer. - -| Advertised condition | ACP option | MCP response | -| --- | --- | --- | -| Always | `Allow` | `action: accept` | -| `persist` contains `session` | `Allow for this session` | `action: accept`, `_meta.persist: session` | -| `persist` contains `always` | `Always allow` | `action: accept`, `_meta.persist: always` | -| Non-tool request | `Deny` | `action: decline` | -| Always | `Cancel` | `action: cancel` | - -Tool-call approvals deliberately have no `Deny` choice: cancellation stops the tool call. For an ordinary MCP request, `Deny` declines the request while allowing the surrounding turn to continue, whereas `Cancel` aborts the request. - -Structured form and URL elicitations use the corresponding ACP elicitation capability when the client advertises it. A structured form that the client cannot render is cancelled rather than replaced with an approval that would omit required input. A message-only or URL request may use permission fallback because no structured field values are lost. - -The Codex app-server currently omits the MCP request identity from form-mode elicitation parameters. `codex-acp` correlates the request with an existing MCP tool call only when exactly one pending call for that thread and server is available. Ambiguous requests receive a unique standalone `toolCallId` and include the full message and schema. - -## Lifecycle and safety - -Permission prompts belong to the active Codex turn. Requests for a stale or interrupted turn are rejected without opening client UI. Cancelling an ACP request, returning an unadvertised `optionId`, transport failure, and malformed client responses all fail closed. - -The adapter does not reconstruct provider effects from ACP `kind` values. In particular, `allow_always` describes presentation intent but does not itself create a policy rule; only the exact Codex decision associated with the selected `optionId` can do that. - -The app-server v2 request methods are the active permission surface: - -- `item/commandExecution/requestApproval` -- `item/fileChange/requestApproval` -- `item/permissions/requestApproval` -- `mcpServer/elicitation/request` - -Deprecated `execCommandApproval` and `applyPatchApproval` methods are not exposed as a second permission pipeline. diff --git a/docs/recommended-config-values-extension.md b/docs/recommended-config-values-extension.md deleted file mode 100644 index e745e047..00000000 --- a/docs/recommended-config-values-extension.md +++ /dev/null @@ -1,53 +0,0 @@ -# Recommended config values extension - -`codex-acp` implements the experimental AIR `recommendedValue` extension for -the model and reasoning-effort session config selectors. It lets clients show a -Codex recommendation independently from the session's current selection. - -## Capability negotiation - -The client opts in during `initialize`: - -```json -{ - "clientCapabilities": { - "_meta": { - "jetbrains": { - "air": { - "version": 1, - "capabilities": ["recommendedValue"] - } - } - } - } -} -``` - -The adapter advertises `recommendedValue` in the corresponding capability list -of its initialize response. Without negotiation, config options retain their -existing shape and contain no recommendation metadata. - -## Config option metadata - -When a recommendation is available, the model or effort selector contains: - -```json -{ - "_meta": { - "jetbrains": { - "air": { - "version": 1, - "recommendedValue": "medium" - } - } - } -} -``` - -The recommended model is the available model marked `isDefault` by Codex. The -recommended effort is the current model's `defaultReasoningEffort`. A value is -emitted only when it is present among that selector's advertised options. - -`recommendedValue` is independent from `currentValue`: explicit user choices -remain current. When the user switches models, the effort recommendation is -recomputed from the newly selected model. diff --git a/docs/session-compaction.md b/docs/session-compaction.md index ef03dc02..be1014e7 100644 --- a/docs/session-compaction.md +++ b/docs/session-compaction.md @@ -20,4 +20,4 @@ Loading a session replays each persisted compaction as one completed update in i Codex's app-server compaction items expose lifecycle identity without a user-displayable summary. The adapter therefore omits `summary` and does not emit `compaction_summary_chunk`. It does not extract internal replacement history or encrypted compaction data. Context utilization continues to arrive separately through `usage_update`. -When the client omits `session.compaction` or sets it to `null`, the adapter preserves its existing synthetic tool-call and text-message fallback. +When the client omits `session.compaction` or sets it to `null`, the adapter preserves its existing synthetic tool-call and text-message fallback. The synthetic tool call carries the AIR `contextCompaction` key, see [AIR extensions](air-extensions.md#context-compaction). diff --git a/docs/subagent-sessions.md b/docs/subagent-sessions.md index 012f14f1..025c6793 100644 --- a/docs/subagent-sessions.md +++ b/docs/subagent-sessions.md @@ -8,7 +8,7 @@ Subagents require bilateral capability negotiation during `initialize`. - The canonical client field is `clientCapabilities.subagents: {}`. - The agent returns `agentCapabilities.sessionCapabilities.subagents: {}`. -- Because released SDKs may strip the draft field, AIR clients can instead advertise `nativeSubagentSessions` in `_meta.jetbrains.air.capabilities`; this adapter always advertises that key in its initialize response. +- Because released SDKs may strip the draft field, AIR clients can instead advertise `nativeSubagentSessions` in `_meta.jetbrains.air.capabilities`; this adapter always advertises that key in its initialize response. See [AIR extensions](air-extensions.md#native-subagent-sessions). - New clients and agents must prefer the canonical field. ## Lifecycle events From 500ca48eebf74f54cc12a1a4716a9b3ce8b499c8 Mon Sep 17 00:00:00 2001 From: Nikita Ashikhmin Date: Wed, 23 Sep 2026 16:18:28 +0400 Subject: [PATCH 05/14] fix: keep kind execute in the approval request of a started MCP tool call The tool call contract sent only the status in the permission request of a started MCP tool call. Before, the request also had kind execute. A client that reads the kind of the request, such as the MCP approval e2e test, no longer found it. Now the request has kind execute again for every client. The compatibility rule in docs/air-extensions.md now says that a permission request omits no field that it had before. --- docs/air-extensions.md | 1 + src/__tests__/tool-calls/tool-call-contract.test.ts | 12 ++++++++++++ src/tool-calls/reporters/ElicitationReporter.ts | 4 ++-- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/docs/air-extensions.md b/docs/air-extensions.md index 1e1dc393..c96b27d3 100644 --- a/docs/air-extensions.md +++ b/docs/air-extensions.md @@ -53,6 +53,7 @@ Only these differences are allowed: - A `tool_call_update` omits a top-level field that did not change since the last report of the same tool call. The permission request of a tool call counts as a report. ACP clients merge an update into the stored tool call. + The permission request itself omits no field that the adapter sent in it before, such as the `kind`. After a cancelled or failed permission request, the next update carries every field again. ACP defines no merge for `_meta` keys, so the `_meta` of each report keeps every key that the adapter sent before. - Bug fixes: a unique MCP startup tool call id, the result of a dynamic tool in `content`, diff --git a/src/__tests__/tool-calls/tool-call-contract.test.ts b/src/__tests__/tool-calls/tool-call-contract.test.ts index b4aa7d57..f4c794c8 100644 --- a/src/__tests__/tool-calls/tool-call-contract.test.ts +++ b/src/__tests__/tool-calls/tool-call-contract.test.ts @@ -150,6 +150,18 @@ describe("ACP tool call contract", () => { .toEqual([{type: "content", content: {type: "text", text: "Pick a value"}}]); }); + it("sends kind execute in the approval request of a started MCP tool call to every client", () => { + const facts = ElicitationReporter.permission({ + threadId: "s", turnId: "t", serverName: "srv", mode: "form", _meta: null, + message: "Allow the tool?", requestedSchema: {type: "object", properties: {}}, + } as never, true, "mcp-1", () => "unused"); + + for (const capabilities of [AIR, ZED, ClientCapabilities.from(null)]) { + expect(new AcpToolCallRenderer(capabilities).renderPermissionToolCall(facts)) + .toEqual({toolCallId: "mcp-1", kind: "execute", status: "pending"}); + } + }); + it("sends trimmed MCP progress text to a client that is not AIR, and none to AIR", () => { const zed = new AcpToolCallRenderer(ZED).render(McpToolReporter.progress("mcp", " line 1\n")); expect(zed._meta).toEqual({mcp_output_delta: {data: "line 1"}}); diff --git a/src/tool-calls/reporters/ElicitationReporter.ts b/src/tool-calls/reporters/ElicitationReporter.ts index b86799c7..672fdfc5 100644 --- a/src/tool-calls/reporters/ElicitationReporter.ts +++ b/src/tool-calls/reporters/ElicitationReporter.ts @@ -16,8 +16,8 @@ export class ElicitationReporter { ): PermissionToolFacts { if (params.mode === "form" || params.mode === "openai/form") { if (correlatedCallId !== undefined) { - // The client already shows the MCP tool call. Only its status changes. - return {toolCallId: correlatedCallId, status: "pending"}; + // The client already shows the MCP tool call. The request keeps the kind, as before the contract. + return {toolCallId: correlatedCallId, kind: "execute", status: "pending"}; } return { toolCallId: nextStandaloneToolCallId(), From 18e5df74f437c5edc9713b5cd141b8ea37b4d94f Mon Sep 17 00:00:00 2001 From: Nikita Ashikhmin Date: Wed, 23 Sep 2026 15:17:24 +0400 Subject: [PATCH 06/14] test: record the tool call reports of each client profile A scenario harness drives the adapter with scripted app-server traffic for every tool kind: commands with output, stdin, and failure; read, search, and list; file changes; MCP tools, approvals, and elicitations; dynamic tools; web search; images; subagents with native, nested and late child sessions and the collaboration controls; fuzzy search; guardian reviews; compaction; plans and plan review; goals; permission requests; MCP startup failures; background terminals; messages and reasoning; and a history replay. The harness records every outbound ACP message for a plain ACP client, Zed, and AIR. The tests: - validate each message against the ACP JSON schema of @agentclientprotocol/sdk; - keep the AIR messages as golden snapshots, one message per line, with the keys in sorted order; - compare the plain and Zed messages with the baseline, the recorded messages of origin/main at 1cc6223. The comparison applies only the allowed differences of the compatibility rule in docs/air-extensions.md; - check that no client gets a tool call field twice with the same value, the Zed conventions, the plain client output, the subagent paths, and the AIR keys. The SDK does not export its zod schemas, and zod cannot read the ACP schema, so Ajv validates it. Ajv is a new dev dependency. Each of these regressions was injected into the adapter and then reverted, to prove that the baseline comparison catches it: - no is_mcp_tool_call for a client that is not AIR: 6 comparisons fail; - Zed gets the command output in content, not in rawOutput: 6 fail; - the plain client loses its first terminal_output_delta chunk: 1 fails; - a client that is not AIR gets every unchanged field again: the repeated-field check fails for plain and Zed. To record the baseline again, see src/__tests__/scenarios/baseline.ts. --- docs/air-extensions.md | 6 + package-lock.json | 1 + package.json | 1 + src/__tests__/scenarios/acp-schema.ts | 63 ++ src/__tests__/scenarios/baseline.ts | 169 ++++ .../scenarios/client-profiles.test.ts | 736 ++++++++++++++++++ .../air/agent-message-and-reasoning.jsonl | 9 + .../data/air/background-terminal.jsonl | 6 + .../scenarios/data/air/collab-agent.jsonl | 4 + .../scenarios/data/air/collab-controls.jsonl | 14 + .../scenarios/data/air/command-approval.jsonl | 8 + .../scenarios/data/air/command-failed.jsonl | 7 + .../data/air/command-output-stdin.jsonl | 9 + .../air/command-without-streamed-output.jsonl | 6 + .../data/air/context-compaction.jsonl | 6 + .../scenarios/data/air/dynamic-tool.jsonl | 6 + .../data/air/file-change-approval.jsonl | 8 + .../scenarios/data/air/file-changes.jsonl | 12 + .../data/air/fuzzy-file-search.jsonl | 8 + .../scenarios/data/air/goal-update.jsonl | 6 + .../scenarios/data/air/guardian-review.jsonl | 6 + .../scenarios/data/air/history-replay.jsonl | 20 + .../scenarios/data/air/image-generation.jsonl | 6 + .../scenarios/data/air/image-view.jsonl | 5 + .../data/air/late-subagent-update.jsonl | 7 + .../scenarios/data/air/mcp-elicitation.jsonl | 6 + .../data/air/mcp-startup-failure.jsonl | 5 + .../data/air/mcp-tool-approval.jsonl | 9 + .../scenarios/data/air/mcp-tool.jsonl | 8 + .../data/air/mcp-url-elicitation.jsonl | 6 + .../data/air/native-subagent-session.jsonl | 12 + .../data/air/nested-subagent-session.jsonl | 11 + .../air/network-and-sandbox-approval.jsonl | 8 + .../data/air/plan-deltas-and-turn-plan.jsonl | 6 + .../data/air/plan-review-permission.jsonl | 7 + .../scenarios/data/air/plan-stream.jsonl | 7 + .../scenarios/data/air/read-search-list.jsonl | 10 + .../data/air/subagent-activity.jsonl | 7 + .../scenarios/data/air/web-search.jsonl | 8 + .../plain/agent-message-and-reasoning.jsonl | 9 + .../baseline/plain/background-terminal.jsonl | 6 + .../data/baseline/plain/collab-agent.jsonl | 6 + .../data/baseline/plain/collab-controls.jsonl | 15 + .../baseline/plain/command-approval.jsonl | 8 + .../data/baseline/plain/command-failed.jsonl | 7 + .../baseline/plain/command-output-stdin.jsonl | 9 + .../command-without-streamed-output.jsonl | 6 + .../baseline/plain/context-compaction.jsonl | 6 + .../data/baseline/plain/dynamic-tool.jsonl | 6 + .../baseline/plain/file-change-approval.jsonl | 8 + .../data/baseline/plain/file-changes.jsonl | 12 + .../baseline/plain/fuzzy-file-search.jsonl | 8 + .../data/baseline/plain/goal-update.jsonl | 6 + .../data/baseline/plain/guardian-review.jsonl | 6 + .../data/baseline/plain/history-replay.jsonl | 21 + .../baseline/plain/image-generation.jsonl | 6 + .../data/baseline/plain/image-view.jsonl | 5 + .../baseline/plain/late-subagent-update.jsonl | 7 + .../data/baseline/plain/mcp-elicitation.jsonl | 5 + .../baseline/plain/mcp-startup-failure.jsonl | 5 + .../baseline/plain/mcp-tool-approval.jsonl | 9 + .../data/baseline/plain/mcp-tool.jsonl | 10 + .../baseline/plain/mcp-url-elicitation.jsonl | 7 + .../plain/native-subagent-session.jsonl | 7 + .../plain/nested-subagent-session.jsonl | 7 + .../plain/network-and-sandbox-approval.jsonl | 8 + .../plain/plan-deltas-and-turn-plan.jsonl | 6 + .../plain/plan-review-permission.jsonl | 7 + .../data/baseline/plain/plan-stream.jsonl | 5 + .../baseline/plain/read-search-list.jsonl | 11 + .../baseline/plain/subagent-activity.jsonl | 8 + .../data/baseline/plain/web-search.jsonl | 8 + .../zed/agent-message-and-reasoning.jsonl | 9 + .../baseline/zed/background-terminal.jsonl | 6 + .../data/baseline/zed/collab-agent.jsonl | 6 + .../data/baseline/zed/collab-controls.jsonl | 15 + .../data/baseline/zed/command-approval.jsonl | 8 + .../data/baseline/zed/command-failed.jsonl | 7 + .../baseline/zed/command-output-stdin.jsonl | 9 + .../zed/command-without-streamed-output.jsonl | 6 + .../baseline/zed/context-compaction.jsonl | 6 + .../data/baseline/zed/dynamic-tool.jsonl | 6 + .../baseline/zed/file-change-approval.jsonl | 8 + .../data/baseline/zed/file-changes.jsonl | 12 + .../data/baseline/zed/fuzzy-file-search.jsonl | 8 + .../data/baseline/zed/goal-update.jsonl | 6 + .../data/baseline/zed/guardian-review.jsonl | 6 + .../data/baseline/zed/history-replay.jsonl | 21 + .../data/baseline/zed/image-generation.jsonl | 6 + .../data/baseline/zed/image-view.jsonl | 5 + .../baseline/zed/late-subagent-update.jsonl | 7 + .../data/baseline/zed/mcp-elicitation.jsonl | 5 + .../baseline/zed/mcp-startup-failure.jsonl | 5 + .../data/baseline/zed/mcp-tool-approval.jsonl | 9 + .../data/baseline/zed/mcp-tool.jsonl | 10 + .../baseline/zed/mcp-url-elicitation.jsonl | 7 + .../zed/native-subagent-session.jsonl | 7 + .../zed/nested-subagent-session.jsonl | 7 + .../zed/network-and-sandbox-approval.jsonl | 8 + .../zed/plan-deltas-and-turn-plan.jsonl | 6 + .../baseline/zed/plan-review-permission.jsonl | 7 + .../data/baseline/zed/plan-stream.jsonl | 5 + .../data/baseline/zed/read-search-list.jsonl | 11 + .../data/baseline/zed/subagent-activity.jsonl | 8 + .../data/baseline/zed/web-search.jsonl | 8 + src/__tests__/scenarios/scenario-harness.ts | 286 +++++++ src/__tests__/scenarios/scenarios.ts | 677 ++++++++++++++++ 107 files changed, 2722 insertions(+) create mode 100644 src/__tests__/scenarios/acp-schema.ts create mode 100644 src/__tests__/scenarios/baseline.ts create mode 100644 src/__tests__/scenarios/client-profiles.test.ts create mode 100644 src/__tests__/scenarios/data/air/agent-message-and-reasoning.jsonl create mode 100644 src/__tests__/scenarios/data/air/background-terminal.jsonl create mode 100644 src/__tests__/scenarios/data/air/collab-agent.jsonl create mode 100644 src/__tests__/scenarios/data/air/collab-controls.jsonl create mode 100644 src/__tests__/scenarios/data/air/command-approval.jsonl create mode 100644 src/__tests__/scenarios/data/air/command-failed.jsonl create mode 100644 src/__tests__/scenarios/data/air/command-output-stdin.jsonl create mode 100644 src/__tests__/scenarios/data/air/command-without-streamed-output.jsonl create mode 100644 src/__tests__/scenarios/data/air/context-compaction.jsonl create mode 100644 src/__tests__/scenarios/data/air/dynamic-tool.jsonl create mode 100644 src/__tests__/scenarios/data/air/file-change-approval.jsonl create mode 100644 src/__tests__/scenarios/data/air/file-changes.jsonl create mode 100644 src/__tests__/scenarios/data/air/fuzzy-file-search.jsonl create mode 100644 src/__tests__/scenarios/data/air/goal-update.jsonl create mode 100644 src/__tests__/scenarios/data/air/guardian-review.jsonl create mode 100644 src/__tests__/scenarios/data/air/history-replay.jsonl create mode 100644 src/__tests__/scenarios/data/air/image-generation.jsonl create mode 100644 src/__tests__/scenarios/data/air/image-view.jsonl create mode 100644 src/__tests__/scenarios/data/air/late-subagent-update.jsonl create mode 100644 src/__tests__/scenarios/data/air/mcp-elicitation.jsonl create mode 100644 src/__tests__/scenarios/data/air/mcp-startup-failure.jsonl create mode 100644 src/__tests__/scenarios/data/air/mcp-tool-approval.jsonl create mode 100644 src/__tests__/scenarios/data/air/mcp-tool.jsonl create mode 100644 src/__tests__/scenarios/data/air/mcp-url-elicitation.jsonl create mode 100644 src/__tests__/scenarios/data/air/native-subagent-session.jsonl create mode 100644 src/__tests__/scenarios/data/air/nested-subagent-session.jsonl create mode 100644 src/__tests__/scenarios/data/air/network-and-sandbox-approval.jsonl create mode 100644 src/__tests__/scenarios/data/air/plan-deltas-and-turn-plan.jsonl create mode 100644 src/__tests__/scenarios/data/air/plan-review-permission.jsonl create mode 100644 src/__tests__/scenarios/data/air/plan-stream.jsonl create mode 100644 src/__tests__/scenarios/data/air/read-search-list.jsonl create mode 100644 src/__tests__/scenarios/data/air/subagent-activity.jsonl create mode 100644 src/__tests__/scenarios/data/air/web-search.jsonl create mode 100644 src/__tests__/scenarios/data/baseline/plain/agent-message-and-reasoning.jsonl create mode 100644 src/__tests__/scenarios/data/baseline/plain/background-terminal.jsonl create mode 100644 src/__tests__/scenarios/data/baseline/plain/collab-agent.jsonl create mode 100644 src/__tests__/scenarios/data/baseline/plain/collab-controls.jsonl create mode 100644 src/__tests__/scenarios/data/baseline/plain/command-approval.jsonl create mode 100644 src/__tests__/scenarios/data/baseline/plain/command-failed.jsonl create mode 100644 src/__tests__/scenarios/data/baseline/plain/command-output-stdin.jsonl create mode 100644 src/__tests__/scenarios/data/baseline/plain/command-without-streamed-output.jsonl create mode 100644 src/__tests__/scenarios/data/baseline/plain/context-compaction.jsonl create mode 100644 src/__tests__/scenarios/data/baseline/plain/dynamic-tool.jsonl create mode 100644 src/__tests__/scenarios/data/baseline/plain/file-change-approval.jsonl create mode 100644 src/__tests__/scenarios/data/baseline/plain/file-changes.jsonl create mode 100644 src/__tests__/scenarios/data/baseline/plain/fuzzy-file-search.jsonl create mode 100644 src/__tests__/scenarios/data/baseline/plain/goal-update.jsonl create mode 100644 src/__tests__/scenarios/data/baseline/plain/guardian-review.jsonl create mode 100644 src/__tests__/scenarios/data/baseline/plain/history-replay.jsonl create mode 100644 src/__tests__/scenarios/data/baseline/plain/image-generation.jsonl create mode 100644 src/__tests__/scenarios/data/baseline/plain/image-view.jsonl create mode 100644 src/__tests__/scenarios/data/baseline/plain/late-subagent-update.jsonl create mode 100644 src/__tests__/scenarios/data/baseline/plain/mcp-elicitation.jsonl create mode 100644 src/__tests__/scenarios/data/baseline/plain/mcp-startup-failure.jsonl create mode 100644 src/__tests__/scenarios/data/baseline/plain/mcp-tool-approval.jsonl create mode 100644 src/__tests__/scenarios/data/baseline/plain/mcp-tool.jsonl create mode 100644 src/__tests__/scenarios/data/baseline/plain/mcp-url-elicitation.jsonl create mode 100644 src/__tests__/scenarios/data/baseline/plain/native-subagent-session.jsonl create mode 100644 src/__tests__/scenarios/data/baseline/plain/nested-subagent-session.jsonl create mode 100644 src/__tests__/scenarios/data/baseline/plain/network-and-sandbox-approval.jsonl create mode 100644 src/__tests__/scenarios/data/baseline/plain/plan-deltas-and-turn-plan.jsonl create mode 100644 src/__tests__/scenarios/data/baseline/plain/plan-review-permission.jsonl create mode 100644 src/__tests__/scenarios/data/baseline/plain/plan-stream.jsonl create mode 100644 src/__tests__/scenarios/data/baseline/plain/read-search-list.jsonl create mode 100644 src/__tests__/scenarios/data/baseline/plain/subagent-activity.jsonl create mode 100644 src/__tests__/scenarios/data/baseline/plain/web-search.jsonl create mode 100644 src/__tests__/scenarios/data/baseline/zed/agent-message-and-reasoning.jsonl create mode 100644 src/__tests__/scenarios/data/baseline/zed/background-terminal.jsonl create mode 100644 src/__tests__/scenarios/data/baseline/zed/collab-agent.jsonl create mode 100644 src/__tests__/scenarios/data/baseline/zed/collab-controls.jsonl create mode 100644 src/__tests__/scenarios/data/baseline/zed/command-approval.jsonl create mode 100644 src/__tests__/scenarios/data/baseline/zed/command-failed.jsonl create mode 100644 src/__tests__/scenarios/data/baseline/zed/command-output-stdin.jsonl create mode 100644 src/__tests__/scenarios/data/baseline/zed/command-without-streamed-output.jsonl create mode 100644 src/__tests__/scenarios/data/baseline/zed/context-compaction.jsonl create mode 100644 src/__tests__/scenarios/data/baseline/zed/dynamic-tool.jsonl create mode 100644 src/__tests__/scenarios/data/baseline/zed/file-change-approval.jsonl create mode 100644 src/__tests__/scenarios/data/baseline/zed/file-changes.jsonl create mode 100644 src/__tests__/scenarios/data/baseline/zed/fuzzy-file-search.jsonl create mode 100644 src/__tests__/scenarios/data/baseline/zed/goal-update.jsonl create mode 100644 src/__tests__/scenarios/data/baseline/zed/guardian-review.jsonl create mode 100644 src/__tests__/scenarios/data/baseline/zed/history-replay.jsonl create mode 100644 src/__tests__/scenarios/data/baseline/zed/image-generation.jsonl create mode 100644 src/__tests__/scenarios/data/baseline/zed/image-view.jsonl create mode 100644 src/__tests__/scenarios/data/baseline/zed/late-subagent-update.jsonl create mode 100644 src/__tests__/scenarios/data/baseline/zed/mcp-elicitation.jsonl create mode 100644 src/__tests__/scenarios/data/baseline/zed/mcp-startup-failure.jsonl create mode 100644 src/__tests__/scenarios/data/baseline/zed/mcp-tool-approval.jsonl create mode 100644 src/__tests__/scenarios/data/baseline/zed/mcp-tool.jsonl create mode 100644 src/__tests__/scenarios/data/baseline/zed/mcp-url-elicitation.jsonl create mode 100644 src/__tests__/scenarios/data/baseline/zed/native-subagent-session.jsonl create mode 100644 src/__tests__/scenarios/data/baseline/zed/nested-subagent-session.jsonl create mode 100644 src/__tests__/scenarios/data/baseline/zed/network-and-sandbox-approval.jsonl create mode 100644 src/__tests__/scenarios/data/baseline/zed/plan-deltas-and-turn-plan.jsonl create mode 100644 src/__tests__/scenarios/data/baseline/zed/plan-review-permission.jsonl create mode 100644 src/__tests__/scenarios/data/baseline/zed/plan-stream.jsonl create mode 100644 src/__tests__/scenarios/data/baseline/zed/read-search-list.jsonl create mode 100644 src/__tests__/scenarios/data/baseline/zed/subagent-activity.jsonl create mode 100644 src/__tests__/scenarios/data/baseline/zed/web-search.jsonl create mode 100644 src/__tests__/scenarios/scenario-harness.ts create mode 100644 src/__tests__/scenarios/scenarios.ts diff --git a/docs/air-extensions.md b/docs/air-extensions.md index c96b27d3..9611f593 100644 --- a/docs/air-extensions.md +++ b/docs/air-extensions.md @@ -65,6 +65,12 @@ The keys of Zed, of upstream ACP, and of other JetBrains teams stay as they were See [JetBrains shared keys](#jetbrains-shared-keys) and [Zed conventions](#zed-conventions). [Codex items and ACP fields](#codex-items-and-acp-fields) lists the fields of each client. +The scenario tests in `src/__tests__/scenarios/` record every outbound message for three client profiles. +The profiles are a plain ACP client, Zed, and AIR. The tests validate each message against the ACP schema. +They keep the AIR messages as snapshots, one message per line. +They compare the messages of the plain client and of Zed with the messages of the adapter before these extensions. +The comparison applies only the differences above. + ## Negotiation ### Client declaration diff --git a/package-lock.json b/package-lock.json index 0f70d4ba..08f5a60f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ }, "devDependencies": { "@types/node": "^26.1.0", + "ajv": "^8.20.0", "esbuild": "^0.28.2", "mcp-hello-world": "^1.1.2", "tsx": "^4.23.12", diff --git a/package.json b/package.json index 95b5c338..d37c2b89 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "type": "module", "devDependencies": { "@types/node": "^26.1.0", + "ajv": "^8.20.0", "esbuild": "^0.28.2", "mcp-hello-world": "^1.1.2", "tsx": "^4.23.12", diff --git a/src/__tests__/scenarios/acp-schema.ts b/src/__tests__/scenarios/acp-schema.ts new file mode 100644 index 00000000..a69c624a --- /dev/null +++ b/src/__tests__/scenarios/acp-schema.ts @@ -0,0 +1,63 @@ +import fs from "node:fs"; +import {createRequire} from "node:module"; +import {Ajv2020, type ValidateFunction} from "ajv/dist/2020.js"; +import type {RecordedMessage} from "./scenario-harness"; + +/** + * Validates the outbound messages of the adapter against the ACP JSON schema that `@agentclientprotocol/sdk` ships. + * The SDK does not export its zod schemas, so Ajv reads the JSON schema file of the package. + */ + +const SCHEMA_ID = "acp"; +const ajv = new Ajv2020({strict: false, allErrors: true}); +ajv.addSchema(JSON.parse(fs.readFileSync( + createRequire(import.meta.url).resolve("@agentclientprotocol/sdk/schema/schema.json"), + "utf8", +)), SCHEMA_ID); + +/** The ACP type of each recorded message. */ +const MESSAGE_TYPES: Record = { + "notify session/update": "SessionNotification", + "request session/request_permission": "RequestPermissionRequest", + "request elicitation/create": "CreateElicitationRequest", + "notify elicitation/complete": "CompleteElicitationNotification", + "response initialize": "InitializeResponse", + "response session/new": "NewSessionResponse", + "response session/load": "LoadSessionResponse", + "response session/prompt": "PromptResponse", +}; + +/** + * Session updates of the AIR extension that the ACP schema does not define. + * The adapter sends them only to a client that negotiated them, see `docs/air-extensions.md`. + */ +export const AIR_SESSION_UPDATES = new Set([ + "subagent_spawned", + "subagent_state_update", + "async_task_spawned", + "async_task_state_update", +]); + +function validator(type: string): ValidateFunction { + const validate = ajv.getSchema(`${SCHEMA_ID}#/$defs/${type}`); + if (validate === undefined) throw new Error(`The ACP schema has no type ${type}`); + return validate; +} + +/** Returns the ACP type of a message, or `null` when the message is not an ACP message of the adapter. */ +export function acpMessageType(message: RecordedMessage): string | null { + if (message.direction === "codexResponse") return null; + const update = (message.params as {update?: {sessionUpdate?: string}} | undefined)?.update; + if (message.method === "session/update" && AIR_SESSION_UPDATES.has(update?.sessionUpdate ?? "")) return null; + const type = MESSAGE_TYPES[`${message.direction} ${message.method}`]; + if (type === undefined) throw new Error(`No ACP type for ${message.direction} ${message.method}`); + return type; +} + +/** Returns the schema errors of one message, or an empty list. */ +export function schemaErrors(message: RecordedMessage): string[] { + const type = acpMessageType(message); + if (type === null) return []; + const validate = validator(type); + return validate(message.params) ? [] : [`${type}: ${ajv.errorsText(validate.errors)}`]; +} diff --git a/src/__tests__/scenarios/baseline.ts b/src/__tests__/scenarios/baseline.ts new file mode 100644 index 00000000..5bfe6b20 --- /dev/null +++ b/src/__tests__/scenarios/baseline.ts @@ -0,0 +1,169 @@ +import {canonical, type RecordedMessage} from "./scenario-harness"; + +/** + * The comparison of the messages for a client that is not AIR with the messages of the adapter before the AIR extensions. + * + * `data/baseline//.jsonl` holds the messages that origin/main at {@link BASELINE_COMMIT} sends. + * The functions here apply the allowed differences of the compatibility rule in `docs/air-extensions.md`. + * The messages of the current adapter must then equal the baseline. + * + * To record the baseline again, copy `src/__tests__/scenarios/` to a checkout of the baseline commit and run + * `RECORD_SCENARIO_BASELINE=1 npx vitest run src/__tests__/scenarios/client-profiles.test.ts -t baseline` there. + * Then copy `data/baseline/` back. + */ +export const BASELINE_COMMIT = "1cc62233fb6f2abde7cadd518a53de9d0826ea77"; + +type Json = Record; + +export type MetaObject = {meta: Json; owner: Json}; + +/** Every `_meta` object of the messages with the object that holds it, except inside `rawInput` and `rawOutput`. */ +export function metaObjects(value: unknown): MetaObject[] { + if (value === null || typeof value !== "object") return []; + if (Array.isArray(value)) return value.flatMap(metaObjects); + const owner = value as Json; + return Object.entries(owner).flatMap(([key, child]) => { + if (key === "rawInput" || key === "rawOutput") return []; + if (key === "_meta" && child !== null && typeof child === "object") { + return [{meta: child as Json, owner}, ...metaObjects(child)]; + } + return metaObjects(child); + }); +} + +const AIR_ONLY_CODEX_KEYS = ["phase", "subagent", "collaboration", "kind", "planItemId"]; + +/** + * The metadata keys that exist only for AIR, including the keys that AIR used before `_meta.jetbrains.air`. + * The `kind` of a diff is the ACP diff kind, not the AIR mode kind. + */ +export function airOnlyKeys({meta, owner}: MetaObject): string[] { + const codex = meta["codex"] as Json | undefined; + const keys = ["jetbrains", "goal", "commandAction", "permission", "contextCompaction", + ...(owner["type"] === "diff" ? [] : ["kind"])]; + return [ + ...keys.filter(key => key in meta), + ...AIR_ONLY_CODEX_KEYS + .filter(key => codex !== undefined && codex !== null && typeof codex === "object" && key in codex) + .map(key => `codex.${key}`), + ]; +} + +/** The value without the AIR-only keys. A `_meta` without keys is removed. */ +function withoutAirOnlyKeys(value: unknown): unknown { + if (value === null || typeof value !== "object") return value; + if (Array.isArray(value)) return value.map(withoutAirOnlyKeys); + const owner = value as Json; + const result: Json = {}; + for (const [key, child] of Object.entries(owner)) { + if (key === "rawInput" || key === "rawOutput") { + result[key] = child; + continue; + } + if (key !== "_meta" || child === null || typeof child !== "object" || Array.isArray(child)) { + result[key] = withoutAirOnlyKeys(child); + continue; + } + const meta = structuredClone(child) as Json; + for (const airKey of airOnlyKeys({meta, owner})) { + if (!airKey.startsWith("codex.")) { + delete meta[airKey]; + continue; + } + const codex = meta["codex"] as Json; + delete codex[airKey.slice("codex.".length)]; + if (Object.keys(codex).length === 0) delete meta["codex"]; + } + const kept = withoutAirOnlyKeys(meta) as Json; + if (Object.keys(kept).length > 0) result[key] = kept; + } + return result; +} + +function sessionUpdate(message: RecordedMessage): Json | undefined { + return message.method === "session/update" ? (message.params as {update: Json}).update : undefined; +} + +/** + * The client gets no AIR-only key. A `session_info_update` that had only AIR-only keys, such as a goal, is not sent. + */ +function withoutAirOnlyMessages(messages: RecordedMessage[]): RecordedMessage[] { + return (withoutAirOnlyKeys(messages) as RecordedMessage[]).filter(message => { + const update = sessionUpdate(message); + return update === undefined || update["sessionUpdate"] !== "session_info_update" || Object.keys(update).length > 1; + }); +} + +/** The result of a dynamic tool that the baseline did not send, by scenario and tool call id. */ +const DYNAMIC_TOOL_RESULTS: Record> = { + "dynamic-tool": {"dyn-1": "Found 2 apps"}, + "history-replay": {"h-dyn": "No apps"}, +}; + +/** + * The bug fixes of the compatibility rule that change the messages of the scenarios: + * - the MCP startup tool call id is unique, so it ends with a random id; + * - the report that completes a dynamic tool call has the result of the tool in `content`. + */ +function withBugFixes(scenario: string, messages: RecordedMessage[]): RecordedMessage[] { + const results = {...DYNAMIC_TOOL_RESULTS[scenario]}; + return messages.map(message => { + const update = sessionUpdate(message); + if (update === undefined || typeof update["toolCallId"] !== "string") return message; + const toolCallId = update["toolCallId"]; + const fixed: Json = {...update}; + if (/^mcp_startup\.[^.]+$/.test(toolCallId)) fixed["toolCallId"] = `${toolCallId}.`; + const result = results[toolCallId]; + if (result !== undefined && update["status"] === "completed") { + fixed["content"] = [{type: "content", content: {type: "text", text: result}}]; + delete results[toolCallId]; + } + return {...message, params: {...(message.params as Json), update: fixed}}; + }); +} + +/** The baseline messages with the allowed differences applied, except the merge of the tool call reports. */ +export function expectedFromBaseline(scenario: string, baseline: RecordedMessage[]): RecordedMessage[] { + return withBugFixes(scenario, withoutAirOnlyMessages(baseline)); +} + +/** + * Each tool call report as the client stores it after the merge. + * + * A `tool_call_update` can omit a top-level field that did not change, because the client merges the update into the + * stored tool call. The permission request of a tool call counts as a report. Each report becomes the whole stored + * tool call. A `tool_call_update` that changes no field and has no `_meta` is removed. + * The `_meta` of each report stays as it is, because ACP defines no merge for the `_meta` keys. + */ +export function mergedReports(messages: RecordedMessage[]): RecordedMessage[] { + const stored = new Map(); + const same = (left: unknown, right: unknown) => JSON.stringify(canonical(left)) === JSON.stringify(canonical(right)); + return messages.flatMap((message): RecordedMessage[] => { + const params = message.params as Json; + const update = sessionUpdate(message); + if (update !== undefined && (update["sessionUpdate"] === "tool_call" || update["sessionUpdate"] === "tool_call_update")) { + const {sessionUpdate: kind, toolCallId, _meta, ...fields} = update; + const key = `${String(params["sessionId"])} ${String(toolCallId)}`; + const previous = kind === "tool_call" ? {} : stored.get(key) ?? {}; + const next = {...previous, ...fields}; + stored.set(key, next); + if (kind === "tool_call_update" && _meta === undefined && same(previous, next)) return []; + const report = {sessionUpdate: kind, toolCallId, fields: next, ...(_meta === undefined ? {} : {_meta})}; + return [{...message, params: {...params, update: report}}]; + } + if (message.method === "session/request_permission") { + const {toolCallId, _meta, ...fields} = params["toolCall"] as Json; + const key = `${String(params["sessionId"])} ${String(toolCallId)}`; + const next = {...stored.get(key) ?? {}, ...fields}; + stored.set(key, next); + const toolCall = {toolCallId, fields: next, ...(_meta === undefined ? {} : {_meta})}; + return [{...message, params: {...params, toolCall}}]; + } + return [message]; + }); +} + +/** One canonical JSON line per message, for a readable failure diff. */ +export function lines(messages: RecordedMessage[]): string[] { + return messages.map(message => JSON.stringify(canonical(message))); +} diff --git a/src/__tests__/scenarios/client-profiles.test.ts b/src/__tests__/scenarios/client-profiles.test.ts new file mode 100644 index 00000000..b0005bbf --- /dev/null +++ b/src/__tests__/scenarios/client-profiles.test.ts @@ -0,0 +1,736 @@ +import type * as acp from "@agentclientprotocol/sdk"; +import fs from "node:fs"; +import path from "node:path"; +import {fileURLToPath} from "node:url"; +import {beforeAll, describe, expect, it} from "vitest"; +import {schemaErrors} from "./acp-schema"; +import {airOnlyKeys, expectedFromBaseline, lines, mergedReports, metaObjects} from "./baseline"; +import { + AIR_CAPABILITY_NAMES, + fromJsonLines, + normalize, + PROFILES, + type ProfileName, + type RecordedMessage, + runScenario, + toJsonLines, +} from "./scenario-harness"; +import {SCENARIOS, type Scenario} from "./scenarios"; + +const PROFILE_NAMES: ProfileName[] = ["plain", "zed", "air"]; + +type Update = Record; + +/** The recorded messages of every scenario for every profile. The scenarios run once per file. */ +const recordings = new Map(); + +function scenario(name: string): Scenario { + const found = SCENARIOS.find(candidate => candidate.name === name); + if (found === undefined) throw new Error(`No scenario ${name}`); + return found; +} + +function recording(profile: ProfileName, name: string): RecordedMessage[] { + const messages = recordings.get(`${profile}/${name}`); + if (messages === undefined) throw new Error(`No recording ${profile}/${name}`); + return messages; +} + +function updates(profile: ProfileName, name: string, toolCallId?: string): Update[] { + return recording(profile, name) + .filter(message => message.method === "session/update") + .map(message => (message.params as {update: Update}).update) + .filter(update => toolCallId === undefined || update["toolCallId"] === toolCallId); +} + +function permissionRequests(profile: ProfileName, name: string): Update[] { + return recording(profile, name) + .filter(message => message.method === "session/request_permission") + .map(message => message.params as Update); +} + +function response(profile: ProfileName, name: string, method: string): Update { + const found = recording(profile, name).find(message => message.direction === "response" && message.method === method); + if (found === undefined) throw new Error(`No ${method} response in ${profile}/${name}`); + return found.params as Update; +} + +beforeAll(async () => { + for (const profile of PROFILE_NAMES) { + for (const each of SCENARIOS) { + recordings.set(`${profile}/${each.name}`, normalize(await runScenario(each, profile))); + } + } +}, 120_000); + +describe("AIR golden snapshots", () => { + for (const each of SCENARIOS) { + it(each.name, async () => { + await expect(toJsonLines(recording("air", each.name))).toMatchFileSnapshot(`data/air/${each.name}.jsonl`); + }); + } +}); + +const RECORD_BASELINE = process.env["RECORD_SCENARIO_BASELINE"] === "1"; + +function baselineFile(profile: ProfileName, name: string): string { + return path.join(path.dirname(fileURLToPath(import.meta.url)), "data", "baseline", profile, `${name}.jsonl`); +} + +describe("clients that are not AIR, compared with the baseline", () => { + for (const profile of ["plain", "zed"] as const) { + for (const each of SCENARIOS) { + it.skipIf(RECORD_BASELINE)(`${profile}: ${each.name} gets the baseline messages with the allowed differences`, () => { + const baseline = fromJsonLines(fs.readFileSync(baselineFile(profile, each.name), "utf8")); + expect(lines(mergedReports(recording(profile, each.name)))) + .toEqual(lines(mergedReports(expectedFromBaseline(each.name, baseline)))); + }); + } + } + + it.runIf(RECORD_BASELINE)("records the baseline", () => { + for (const profile of ["plain", "zed"] as const) { + for (const each of SCENARIOS) { + fs.mkdirSync(path.dirname(baselineFile(profile, each.name)), {recursive: true}); + fs.writeFileSync(baselineFile(profile, each.name), toJsonLines(recording(profile, each.name))); + } + } + }); +}); + +describe("ACP schema", () => { + for (const profile of PROFILE_NAMES) { + it(`accepts every outbound message of the ${profile} profile`, () => { + const errors = SCENARIOS.flatMap(each => recording(profile, each.name) + .flatMap(message => schemaErrors(message).map(error => `${each.name} ${message.method}: ${error}`))); + expect(errors).toEqual([]); + }); + } + + it("rejects an invalid tool call", () => { + expect(schemaErrors({ + direction: "notify", + method: "session/update", + params: {sessionId: "s", update: {sessionUpdate: "tool_call", toolCallId: "t", title: "t", status: "done"}}, + })).not.toEqual([]); + }); +}); + +describe("clients that are not AIR", () => { + for (const profile of ["plain", "zed"] as const) { + it(`${profile}: gets no AIR-only metadata key`, () => { + const found = SCENARIOS.flatMap(each => metaObjects(recording(profile, each.name)) + .flatMap(meta => airOnlyKeys(meta).map(key => `${each.name}: ${key}`))); + expect(found).toEqual([]); + }); + + it(`${profile}: gets no terminal_input, because the stdin comes as an output chunk`, () => { + const found = SCENARIOS.flatMap(each => metaObjects(recording(profile, each.name)) + .filter(({meta}) => "terminal_input" in meta) + .map(() => each.name)); + expect(found).toEqual([]); + }); + + it(`${profile}: gets no session_info_update for a goal`, () => { + expect(updates(profile, "goal-update").filter(update => update["sessionUpdate"] === "session_info_update")) + .toEqual([{sessionUpdate: "session_info_update", title: "Go"}]); + }); + + it(`${profile}: gets the output and the exit code of every command in rawOutput`, () => { + const ends = [ + ...updates(profile, "command-output-stdin", "cmd-1"), + ...updates(profile, "read-search-list"), + ...updates(profile, "command-failed", "cmd-3"), + ].filter(update => update["status"] === "completed" || update["status"] === "failed"); + expect(ends.map(update => update["rawOutput"])).toEqual([ + {formatted_output: "Running tests\n1 passed\n", exit_code: 0}, + {formatted_output: "export const a = 1;\n", exit_code: 0}, + {formatted_output: "src/app.ts:3: // TODO\n", exit_code: 0}, + {formatted_output: "app.ts\n", exit_code: 0}, + {formatted_output: "cat: missing.txt: No such file\n", exit_code: 1}, + ]); + expect(ends.every(update => update["content"] === undefined)).toBe(true); + }); + + it(`${profile}: gets the full item fields of the tool kinds that AIR reports in another shape`, () => { + expect(updates(profile, "mcp-tool", "mcp-1").at(-1)).toMatchObject({ + rawOutput: {result: {content: [{type: "text", text: "3 hits"}], structuredContent: {hits: 3}}, error: null}, + }); + expect(updates(profile, "mcp-tool", "mcp-1").at(-1)).not.toHaveProperty("content"); + expect(updates(profile, "web-search", "web-1")[0]!["rawInput"]) + .toEqual({type: "webSearch", id: "web-1", query: "", action: null}); + expect(updates(profile, "collab-agent", "collab-1")[0]!["rawInput"]).toMatchObject({ + prompt: "Find the weather in Paris.", + agentsStates: {"child-thread": {status: "running", message: "Checking"}}, + status: "inProgress", + }); + expect(updates(profile, "collab-agent", "collab-1")[0]).not.toHaveProperty("content"); + expect(updates(profile, "guardian-review")[0]!["content"]).toEqual([{ + type: "content", + content: { + type: "text", + text: "Status: In progress\nAction: shell rm -rf build\nRisk: medium\nAuthorization: unknown\nRationale: Checking.", + }, + }]); + expect(updates(profile, "image-generation", "gen-1")[0]!["rawInput"]).toEqual({id: "gen-1"}); + expect(permissionRequests(profile, "plan-review-permission")[0]!["toolCall"]["rawInput"]) + .toEqual({plan: "# Plan\n\n1. Make the change."}); + }); + + it(`${profile}: gets the plan once when the plan completes`, () => { + expect(updates(profile, "plan-stream").filter(update => update["sessionUpdate"] === "agent_message_chunk")) + .toEqual([{ + sessionUpdate: "agent_message_chunk", + messageId: "plan-3", + content: {type: "text", text: "# Plan\n\n1. Read.\n2. Write."}, + }]); + }); + + it(`${profile}: gets the pre-contract permission request of a started command`, () => { + expect(permissionRequests(profile, "command-approval")[0]).toEqual(expect.objectContaining({ + toolCall: { + toolCallId: "cmd-a", + kind: "execute", + status: "pending", + title: "Run command", + rawInput: {command: "npm install", cwd: "/workspace"}, + }, + })); + expect(permissionRequests(profile, "command-approval")[0]).not.toHaveProperty("_meta"); + }); + } +}); + +/** + * The tool call fields that a report repeats with the same value, and the tool call updates without a field. + * The client merges the tool call of a permission request like an update. + * The request itself carries the title and the parameters on purpose, so only updates are checked. + */ +function repeatedFields(profile: ProfileName): string[] { + const repeated: string[] = []; + for (const each of SCENARIOS) { + const reported = new Map>(); + const reports = recording(profile, each.name).flatMap((message): Update[] => { + if (message.method === "session/update") return [(message.params as {update: Update}).update]; + if (message.method === "session/request_permission") { + return [{sessionUpdate: "permission", ...(message.params as Update)["toolCall"]}]; + } + return []; + }); + for (const update of reports) { + if (update["sessionUpdate"] === "permission") { + const fields = reported.get(update["toolCallId"]) ?? new Map(); + reported.set(update["toolCallId"], fields); + for (const [name, value] of Object.entries(update)) fields.set(name, JSON.stringify(value)); + continue; + } + if (update["sessionUpdate"] !== "tool_call" && update["sessionUpdate"] !== "tool_call_update") continue; + if (update["sessionUpdate"] === "tool_call_update" && Object.keys(update).length <= 2) { + repeated.push(`${each.name} ${update["toolCallId"]} without a field`); + } + const fields = update["sessionUpdate"] === "tool_call" + ? new Map() + : reported.get(update["toolCallId"]) ?? new Map(); + reported.set(update["toolCallId"], fields); + for (const name of ["title", "kind", "status", "content", "locations", "rawInput", "rawOutput"]) { + if (update[name] === undefined) continue; + const value = JSON.stringify(update[name]); + if (fields.get(name) === value) repeated.push(`${each.name} ${update["toolCallId"]} ${name}`); + fields.set(name, value); + } + } + } + return repeated; +} + +describe("every client", () => { + for (const profile of PROFILE_NAMES) { + it(`${profile}: gets no tool call field twice with the same value, and no tool call update without a field`, () => { + expect(repeatedFields(profile)).toEqual([]); + }); + + it(`${profile}: gets empty locations when a fuzzy search finds no file`, () => { + const reported = updates(profile, "fuzzy-file-search", "fuzzyFileSearch.search-1"); + expect(reported.map(update => update["locations"])).toEqual([ + [{path: "/workspace/src/Handler.ts"}], + [{path: "/workspace/src/OtherHandler.ts"}], + [], + undefined, + ]); + }); + } +}); + +/** The session and the update kind of each session update, with the tool call id when there is one. */ +function timeline(profile: ProfileName, name: string): string[] { + return recording(profile, name) + .filter(message => message.method === "session/update") + .map(message => message.params as {sessionId: string; update: Update}) + .filter(({update}) => update["sessionUpdate"] !== "session_info_update") + .map(({sessionId, update}) => [sessionId, update["sessionUpdate"], update["toolCallId"] ?? update["subagentSessionId"]] + .filter(part => part !== undefined).join(" ")); +} + +const SUBAGENT_SCENARIOS = ["native-subagent-session", "nested-subagent-session", "late-subagent-update", "collab-controls"]; + +describe("subagents", () => { + for (const profile of ["plain", "zed"] as const) { + it(`${profile}: gets the legacy tool calls on the root session and no child session`, () => { + for (const name of SUBAGENT_SCENARIOS) { + const sessions = new Set(recording(profile, name) + .filter(message => message.method === "session/update") + .map(message => (message.params as {sessionId: string}).sessionId)); + expect(sessions).toEqual(new Set(["session-1"])); + expect(timeline(profile, name).some(entry => entry.includes("subagent_"))).toBe(false); + } + expect(timeline(profile, "collab-controls")).toEqual([ + "session-1 tool_call spawn-1", + "session-1 tool_call act-1", + "session-1 tool_call wait-1", + "session-1 tool_call_update wait-1", + "session-1 tool_call send-1", + "session-1 tool_call_update send-1", + "session-1 tool_call resume-1", + "session-1 tool_call_update resume-1", + "session-1 tool_call close-1", + "session-1 tool_call_update close-1", + "session-1 tool_call_update spawn-1", + ]); + }); + } + + it("air: gets the child session before the child output, and the child state on the parent", () => { + expect(timeline("air", "native-subagent-session")).toEqual([ + "session-1 subagent_spawned child-thread", + "child-thread tool_call child-cmd", + "child-thread tool_call_update child-cmd", + "child-thread tool_call_update child-cmd", + "child-thread tool_call child-mcp", + "child-thread tool_call_update child-mcp", + "child-thread agent_message_chunk", + "session-1 subagent_state_update child-thread", + ]); + }); + + it("air: gets a nested child on its immediate parent", () => { + expect(timeline("air", "nested-subagent-session")).toEqual([ + "session-1 subagent_spawned child-thread", + "child-thread subagent_spawned grandchild-thread", + "grandchild-thread tool_call grandchild-cmd", + "grandchild-thread tool_call_update grandchild-cmd", + "grandchild-thread agent_message_chunk", + "child-thread subagent_state_update grandchild-thread", + "session-1 subagent_state_update child-thread", + ]); + }); + + it("air: gets no child update after the child ends", () => { + expect(timeline("air", "late-subagent-update")).toEqual([ + "session-1 subagent_spawned child-thread", + "child-thread tool_call child-cmd", + "session-1 subagent_state_update child-thread", + ]); + }); + + it("air: gets wait, sendInput, resumeAgent and closeAgent as tool calls without _meta.jetbrains.air.subagent", () => { + const controls = updates("air", "collab-controls") + .filter(update => ["wait-1", "send-1", "resume-1", "close-1"].includes(update["toolCallId"])); + expect(controls.map(update => [update["sessionUpdate"], update["title"]])).toEqual([ + ["tool_call", "wait"], + ["tool_call_update", undefined], + ["tool_call", "sendInput"], + ["tool_call_update", undefined], + ["tool_call", "resumeAgent"], + ["tool_call_update", undefined], + ["tool_call", "closeAgent"], + ["tool_call_update", undefined], + ]); + expect(controls.every(update => update["_meta"] === undefined)).toBe(true); + }); +}); + +describe("plain ACP client", () => { + it("gets terminal_output_delta chunks, the stdin on its own line, and the output in rawOutput at the end", () => { + expect(updates("plain", "command-output-stdin", "cmd-1")).toEqual([ + expect.objectContaining({sessionUpdate: "tool_call", content: [{type: "terminal", terminalId: "cmd-1"}]}), + { + sessionUpdate: "tool_call_update", + toolCallId: "cmd-1", + _meta: {terminal_output_delta: {data: "Running tests\n", terminal_id: "cmd-1"}}, + }, + { + sessionUpdate: "tool_call_update", + toolCallId: "cmd-1", + _meta: {terminal_output_delta: {data: "\ny\n", terminal_id: "cmd-1"}}, + }, + { + sessionUpdate: "tool_call_update", + toolCallId: "cmd-1", + _meta: {terminal_output_delta: {data: "1 passed\n", terminal_id: "cmd-1"}}, + }, + { + sessionUpdate: "tool_call_update", + toolCallId: "cmd-1", + status: "completed", + rawOutput: {formatted_output: "Running tests\n1 passed\n", exit_code: 0}, + _meta: {terminal_exit: {exit_code: 0, signal: null, terminal_id: "cmd-1"}}, + }, + ]); + }); + + it("gets the output of a command that did not stream in one terminal_output_delta chunk at the end", () => { + expect(updates("plain", "command-without-streamed-output", "cmd-2").at(-1)!["_meta"]).toEqual({ + terminal_output_delta: {data: "a.txt\nb.txt\n", terminal_id: "cmd-2"}, + terminal_exit: {exit_code: 0, signal: null, terminal_id: "cmd-2"}, + }); + }); + + it("gets a replayed command with terminal_output_delta, terminal_exit and rawOutput", () => { + expect(updates("plain", "history-replay", "h-cmd").at(-1)).toEqual({ + sessionUpdate: "tool_call_update", + toolCallId: "h-cmd", + rawOutput: {formatted_output: "1 passed\n", exit_code: 0}, + _meta: { + terminal_output_delta: {data: "1 passed\n", terminal_id: "h-cmd"}, + terminal_exit: {exit_code: 0, signal: null, terminal_id: "h-cmd"}, + }, + }); + }); +}); + +describe("a client that declares terminal_output_delta and is not AIR", () => { + const capabilities: acp.ClientCapabilities = {_meta: {terminal_output_delta: true}}; + + async function commandUpdates(name: string, toolCallId: string): Promise { + return normalize(await runScenario(scenario(name), capabilities)) + .filter(message => message.method === "session/update") + .map(message => (message.params as {update: Update}).update) + .filter(update => update["toolCallId"] === toolCallId); + } + + it("gets terminal_output_delta chunks, the stdin on its own line, and no rawOutput at the end", async () => { + expect((await commandUpdates("command-output-stdin", "cmd-1")).slice(1)).toEqual([ + { + sessionUpdate: "tool_call_update", + toolCallId: "cmd-1", + _meta: {terminal_output_delta: {data: "Running tests\n", terminal_id: "cmd-1"}}, + }, + { + sessionUpdate: "tool_call_update", + toolCallId: "cmd-1", + _meta: {terminal_output_delta: {data: "\ny\n", terminal_id: "cmd-1"}}, + }, + { + sessionUpdate: "tool_call_update", + toolCallId: "cmd-1", + _meta: {terminal_output_delta: {data: "1 passed\n", terminal_id: "cmd-1"}}, + }, + { + sessionUpdate: "tool_call_update", + toolCallId: "cmd-1", + status: "completed", + _meta: {terminal_exit: {exit_code: 0, signal: null, terminal_id: "cmd-1"}}, + }, + ]); + }); + + it("gets the output of a command that did not stream in one terminal_output_delta chunk at the end", async () => { + expect((await commandUpdates("command-without-streamed-output", "cmd-2")).at(-1)).toEqual({ + sessionUpdate: "tool_call_update", + toolCallId: "cmd-2", + status: "completed", + _meta: { + terminal_output_delta: {data: "a.txt\nb.txt\n", terminal_id: "cmd-2"}, + terminal_exit: {exit_code: 0, signal: null, terminal_id: "cmd-2"}, + }, + }); + }); + + it("gets a replayed command with terminal_output_delta, terminal_exit and rawOutput", async () => { + expect((await commandUpdates("history-replay", "h-cmd")).at(-1)).toEqual({ + sessionUpdate: "tool_call_update", + toolCallId: "h-cmd", + rawOutput: {formatted_output: "1 passed\n", exit_code: 0}, + _meta: { + terminal_output_delta: {data: "1 passed\n", terminal_id: "h-cmd"}, + terminal_exit: {exit_code: 0, signal: null, terminal_id: "h-cmd"}, + }, + }); + }); +}); + +describe("Zed", () => { + it("declares terminal_output", () => { + expect(PROFILES.zed._meta).toMatchObject({terminal_output: true}); + }); + + it("gets terminal_info and the terminal content block on the command tool call", () => { + expect(updates("zed", "command-output-stdin", "cmd-1")[0]).toEqual({ + sessionUpdate: "tool_call", + toolCallId: "cmd-1", + kind: "execute", + title: "npm test", + status: "in_progress", + content: [{type: "terminal", terminalId: "cmd-1"}], + rawInput: {command: "npm test", cwd: "/workspace"}, + _meta: {terminal_info: {cwd: "/workspace", terminal_id: "cmd-1"}}, + }); + }); + + it("gets terminal_output chunks, the stdin on its own line, and terminal_exit at the end", () => { + expect(updates("zed", "command-output-stdin", "cmd-1").slice(1)).toEqual([ + { + sessionUpdate: "tool_call_update", + toolCallId: "cmd-1", + _meta: {terminal_output: {data: "Running tests\n", terminal_id: "cmd-1"}}, + }, + { + sessionUpdate: "tool_call_update", + toolCallId: "cmd-1", + _meta: {terminal_output: {data: "\ny\n", terminal_id: "cmd-1"}}, + }, + { + sessionUpdate: "tool_call_update", + toolCallId: "cmd-1", + _meta: {terminal_output: {data: "1 passed\n", terminal_id: "cmd-1"}}, + }, + { + sessionUpdate: "tool_call_update", + toolCallId: "cmd-1", + status: "completed", + rawOutput: {formatted_output: "Running tests\n1 passed\n", exit_code: 0}, + _meta: {terminal_exit: {exit_code: 0, signal: null, terminal_id: "cmd-1"}}, + }, + ]); + }); + + it("gets the output of a command that did not stream in one terminal_output chunk at the end", () => { + expect(updates("zed", "command-without-streamed-output", "cmd-2").at(-1)).toEqual({ + sessionUpdate: "tool_call_update", + toolCallId: "cmd-2", + status: "completed", + rawOutput: {formatted_output: "a.txt\nb.txt\n", exit_code: 0}, + _meta: { + terminal_output: {data: "a.txt\nb.txt\n", terminal_id: "cmd-2"}, + terminal_exit: {exit_code: 0, signal: null, terminal_id: "cmd-2"}, + }, + }); + }); + + it("gets a replayed command with terminal_output and terminal_exit", () => { + const replayed = updates("zed", "history-replay", "h-cmd"); + expect(replayed[0]).toMatchObject({ + content: [{type: "terminal", terminalId: "h-cmd"}], + _meta: {terminal_info: {cwd: "/workspace", terminal_id: "h-cmd"}}, + }); + expect(replayed.at(-1)!["_meta"]).toEqual({ + terminal_output: {data: "1 passed\n", terminal_id: "h-cmd"}, + terminal_exit: {exit_code: 0, signal: null, terminal_id: "h-cmd"}, + }); + }); + + it("gets terminal_output_delta chunks for a read command, because it shows no terminal", () => { + expect(updates("zed", "read-search-list", "read-1").map(update => update["_meta"])).toEqual([ + undefined, + {terminal_output_delta: {data: "export const a = 1;\n", terminal_id: "read-1"}}, + undefined, + ]); + }); + + it("keeps is_mcp_tool_call and the trimmed progress text in mcp_output_delta", () => { + const mcp = updates("zed", "mcp-tool", "mcp-1"); + expect(mcp[0]!["_meta"]).toEqual({is_mcp_tool_call: true}); + expect(mcp[1]!["_meta"]).toEqual({mcp_output_delta: {data: "fetching page 1"}}); + expect(mcp[2]!["_meta"]).toEqual({mcp_output_delta: {data: "fetching page 2"}}); + }); +}); + +describe("AIR", () => { + it("declares the AIR extension with every capability that the adapter knows and terminal_output_delta", () => { + expect(PROFILES.air._meta).toMatchObject({ + terminal_output_delta: true, + jetbrains: {air: {version: 1, capabilities: AIR_CAPABILITY_NAMES}}, + }); + const air = response("air", "command-output-stdin", "initialize")["_meta"]["jetbrains"]["air"]; + expect(air).toEqual({ + version: 1, + goal: {version: 1, controlMethod: "_session/goal", actions: ["set", "pause", "resume", "clear"]}, + capabilities: expect.any(Array), + }); + expect([...air["capabilities"]].sort()).toEqual([...AIR_CAPABILITY_NAMES].sort()); + }); + + it("gets terminal_output_delta chunks, the raw stdin in terminal_input, and terminal_exit without rawOutput", () => { + expect(updates("air", "command-output-stdin", "cmd-1").slice(1)).toEqual([ + { + sessionUpdate: "tool_call_update", + toolCallId: "cmd-1", + _meta: {terminal_output_delta: {data: "Running tests\n", terminal_id: "cmd-1"}}, + }, + {sessionUpdate: "tool_call_update", toolCallId: "cmd-1", _meta: {terminal_input: {data: "y", terminal_id: "cmd-1"}}}, + { + sessionUpdate: "tool_call_update", + toolCallId: "cmd-1", + _meta: {terminal_output_delta: {data: "1 passed\n", terminal_id: "cmd-1"}}, + }, + { + sessionUpdate: "tool_call_update", + toolCallId: "cmd-1", + status: "completed", + _meta: {terminal_exit: {exit_code: 0, signal: null, terminal_id: "cmd-1"}}, + }, + ]); + }); + + it("gets the output of a read, search or list command once, in content", () => { + const ends = updates("air", "read-search-list").filter(update => update["status"] === "completed"); + expect(ends.map(update => [update["content"], update["rawOutput"], update["_meta"]])).toEqual([ + [[{type: "content", content: {type: "text", text: "export const a = 1;\n"}}], undefined, undefined], + [[{type: "content", content: {type: "text", text: "src/app.ts:3: // TODO\n"}}], undefined, undefined], + [[{type: "content", content: {type: "text", text: "app.ts\n"}}], undefined, undefined], + ]); + }); + + it("gets the AIR keys in _meta.jetbrains.air", () => { + const air = (meta: unknown) => (meta as {jetbrains: {air: Record}}).jetbrains.air; + const message = updates("air", "agent-message-and-reasoning") + .find(update => update["sessionUpdate"] === "agent_message_chunk" && update["_meta"] !== undefined); + expect(air(message!["_meta"])).toEqual({version: 1, phase: "final_answer"}); + expect(updates("air", "goal-update").filter(update => update["_meta"] !== undefined).map(update => air(update["_meta"]))) + .toEqual([ + {version: 1, goal: expect.objectContaining({objective: "Ship it", status: "active"})}, + {version: 1, goal: null}, + ]); + const newSession = response("air", "command-output-stdin", "session/new"); + expect(air(newSession["modes"]["availableModes"][0]["_meta"])).toEqual({version: 1, kind: "standard"}); + expect(air(newSession["configOptions"][0]["options"][0]["_meta"])).toEqual({version: 1, kind: "standard"}); + const commands = updates("air", "history-replay") + .find(update => update["sessionUpdate"] === "available_commands_update")!["availableCommands"]; + expect(air(commands[0]["_meta"])).toEqual({ + version: 1, + commandAction: { + kind: "setConfigOption", + configId: "collaboration_mode", + value: "plan", + resetValue: "default", + presentation: "state", + }, + }); + const permission = permissionRequests("air", "command-approval")[0]!; + expect(air(permission["_meta"])).toEqual({ + version: 1, + permission: {version: 1, title: "Run command?", description: "Install the dependencies."}, + }); + expect(air(updates("air", "context-compaction", "compact-1")[0]!["_meta"])) + .toEqual({version: 1, contextCompaction: {version: 1}}); + }); + + it("gets no MCP progress", () => { + const text = JSON.stringify(recording("air", "mcp-tool")); + expect(text).not.toContain("mcp_output_delta"); + expect(text).not.toContain("fetching page"); + }); + + it("gets the MCP result and error in rawOutput = {result, error}, and no copy in content", () => { + const ends = ["mcp-1", "mcp-2"].map(id => updates("air", "mcp-tool", id).at(-1)!); + expect(ends.map(update => update["rawOutput"])).toEqual([ + {result: {content: [{type: "text", text: "3 hits"}], structuredContent: {hits: 3}, _meta: null}, error: null}, + {result: null, error: {message: "server exploded"}}, + ]); + expect(ends.map(update => update["content"])).toEqual([undefined, undefined]); + }); + + it("gets the plan text in rawInput.plan of the plan review, and no plan review metadata", () => { + const review = permissionRequests("air", "plan-review-permission")[0]!; + expect(review["toolCall"]["rawInput"]).toEqual({plan: "# Plan\n\n1. Make the change."}); + expect(review).not.toHaveProperty("_meta"); + }); + + it("gets a streamed plan as plan_update snapshots and _meta.jetbrains.air.contentDelta appends", () => { + expect(updates("air", "plan-stream").filter(update => update["sessionUpdate"] === "plan_update")).toEqual([ + {sessionUpdate: "plan_update", plan: {type: "markdown", planId: "plan-3", content: "# Plan\n\n"}}, + { + sessionUpdate: "plan_update", + plan: {type: "markdown", planId: "plan-3", content: ""}, + _meta: {jetbrains: {air: {version: 1, contentDelta: "1. Read."}}}, + }, + { + sessionUpdate: "plan_update", + plan: {type: "markdown", planId: "plan-3", content: ""}, + _meta: {jetbrains: {air: {version: 1, contentDelta: "\n2. Write."}}}, + }, + ]); + }); + + const airWithoutNativeSubagents = { + ...PROFILES.air, + _meta: { + terminal_output_delta: true, + jetbrains: {air: {version: 1, capabilities: AIR_CAPABILITY_NAMES.filter(name => name !== "nativeSubagentSessions")}}, + }, + }; + + /** The `collab-agent` scenario with another Codex collaboration tool. */ + function collabScenario(tool: string): Scenario { + const base = scenario("collab-agent"); + return { + ...base, + steps: base.steps!.map(step => { + if (!("notify" in step)) return step; + const params = step.notify["params"] as {item: Record}; + return {notify: {...step.notify, params: {...params, item: {...params.item, tool}}}}; + }), + }; + } + + async function collabUpdates(tool: string): Promise { + const messages = normalize(await runScenario(collabScenario(tool), airWithoutNativeSubagents)); + return messages.map(message => (message.params as {update?: Update}).update) + .filter((update): update is Update => update?.["toolCallId"] === "collab-1"); + } + + it("gets _meta.jetbrains.air.subagent and the collaboration keys in rawInput on a spawn without native subagent sessions", async () => { + const reported = await collabUpdates("spawnAgent"); + expect(reported[0]).toEqual({ + sessionUpdate: "tool_call", + toolCallId: "collab-1", + kind: "other", + title: "spawnAgent", + status: "in_progress", + rawInput: { + prompt: "Find the weather in Paris.", + senderThreadId: "session-1", + receiverThreadIds: ["child-thread"], + agentsStates: {"child-thread": {status: "running", message: "Checking"}}, + model: null, + reasoningEffort: null, + }, + _meta: {jetbrains: {air: {version: 1, subagent: true}}}, + }); + expect(reported[1]).toMatchObject({ + status: "completed", + rawInput: expect.objectContaining({agentsStates: {"child-thread": {status: "completed", message: "Sunny"}}}), + }); + expect(reported.every(update => update["rawOutput"] === undefined)).toBe(true); + }); + + for (const tool of ["wait", "sendInput", "resumeAgent", "closeAgent"]) { + it(`gets no _meta.jetbrains.air.subagent on ${tool}, which controls an existing subagent`, async () => { + const reported = await collabUpdates(tool); + expect(reported[0]!["rawInput"]).toEqual(expect.objectContaining({ + senderThreadId: "session-1", + receiverThreadIds: ["child-thread"], + agentsStates: {"child-thread": {status: "running", message: "Checking"}}, + })); + expect(reported.map(update => update["_meta"])).toEqual([undefined, undefined]); + }); + } + + it("gets no key of the pre-contract shape", () => { + const text = SCENARIOS.map(each => JSON.stringify(recording("air", each.name))).join("\n"); + expect(text).not.toContain("formatted_output"); + expect(text).not.toContain("\"codex\""); + expect(text).not.toContain("diffStats"); + expect(text).not.toContain("\"terminal_output\""); + }); +}); diff --git a/src/__tests__/scenarios/data/air/agent-message-and-reasoning.jsonl b/src/__tests__/scenarios/data/air/agent-message-and-reasoning.jsonl new file mode 100644 index 00000000..12c34361 --- /dev/null +++ b/src/__tests__/scenarios/data/air/agent-message-and-reasoning.jsonl @@ -0,0 +1,9 @@ +{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"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":"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":"Always ask to edit external files and use the internet","name":"Ask for approval","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Approve for me","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"}],"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Always ask to edit external files and use the internet","id":"read-only","name":"Ask for approval"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Approve for me"},{"_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":{"text":"Thinking about it","type":"text"},"messageId":"rs-1","sessionUpdate":"agent_thought_chunk"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"content":{"text":"\n\n","type":"text"},"messageId":"rs-1","sessionUpdate":"agent_thought_chunk"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"content":{"text":"raw reasoning","type":"text"},"messageId":"rs-1","sessionUpdate":"agent_thought_chunk"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"_meta":{"jetbrains":{"air":{"phase":"final_answer","version":1}}},"content":{"text":"Hello ","type":"text"},"messageId":"msg-1","sessionUpdate":"agent_message_chunk"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"_meta":{"jetbrains":{"air":{"phase":"final_answer","version":1}}},"content":{"text":"world","type":"text"},"messageId":"msg-1","sessionUpdate":"agent_message_chunk"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"session_info_update","title":"Go"}}} +{"direction":"response","method":"session/prompt","params":{"_meta":{"quota":{"model_usage":[],"token_count":null}},"stopReason":"end_turn","usage":null}} diff --git a/src/__tests__/scenarios/data/air/background-terminal.jsonl b/src/__tests__/scenarios/data/air/background-terminal.jsonl new file mode 100644 index 00000000..42926617 --- /dev/null +++ b/src/__tests__/scenarios/data/air/background-terminal.jsonl @@ -0,0 +1,6 @@ +{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"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":"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":"Always ask to edit external files and use the internet","name":"Ask for approval","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Approve for me","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"}],"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Always ask to edit external files and use the internet","id":"read-only","name":"Ask for approval"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Approve for me"},{"_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":{"_meta":{"terminal_info":{"cwd":"/workspace","terminal_id":"bg-1"}},"content":[{"terminalId":"bg-1","type":"terminal"}],"kind":"execute","name":"exec_command","rawInput":{"command":"npm run dev","cwd":"/workspace"},"sessionUpdate":"tool_call","status":"in_progress","title":"npm run dev","toolCallId":"bg-1"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"_meta":{"terminal_output_delta":{"data":"listening on 3000\n","terminal_id":"bg-1"}},"sessionUpdate":"tool_call_update","toolCallId":"bg-1"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"session_info_update","title":"Go"}}} +{"direction":"response","method":"session/prompt","params":{"_meta":{"quota":{"model_usage":[],"token_count":null}},"stopReason":"end_turn","usage":null}} diff --git a/src/__tests__/scenarios/data/air/collab-agent.jsonl b/src/__tests__/scenarios/data/air/collab-agent.jsonl new file mode 100644 index 00000000..583d4904 --- /dev/null +++ b/src/__tests__/scenarios/data/air/collab-agent.jsonl @@ -0,0 +1,4 @@ +{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"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":"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":"Always ask to edit external files and use the internet","name":"Ask for approval","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Approve for me","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"}],"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Always ask to edit external files and use the internet","id":"read-only","name":"Ask for approval"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Approve for me"},{"_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":{"sessionUpdate":"session_info_update","title":"Go"}}} +{"direction":"response","method":"session/prompt","params":{"_meta":{"quota":{"model_usage":[],"token_count":null}},"stopReason":"end_turn","usage":null}} diff --git a/src/__tests__/scenarios/data/air/collab-controls.jsonl b/src/__tests__/scenarios/data/air/collab-controls.jsonl new file mode 100644 index 00000000..71417a67 --- /dev/null +++ b/src/__tests__/scenarios/data/air/collab-controls.jsonl @@ -0,0 +1,14 @@ +{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"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":"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":"Always ask to edit external files and use the internet","name":"Ask for approval","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Approve for me","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"}],"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Always ask to edit external files and use the internet","id":"read-only","name":"Ask for approval"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Approve for me"},{"_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":{"capabilities":{},"name":"Weather","sessionUpdate":"subagent_spawned","subagentSessionId":"child-thread","task":"Find the weather in Paris."}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"kind":"other","rawInput":{"agentsStates":{"child-thread":{"message":null,"status":"running"}},"model":null,"prompt":null,"reasoningEffort":null,"receiverThreadIds":["child-thread"],"senderThreadId":"session-1"},"sessionUpdate":"tool_call","status":"in_progress","title":"wait","toolCallId":"wait-1"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"wait-1"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"kind":"other","rawInput":{"agentsStates":{"child-thread":{"message":null,"status":"running"}},"model":null,"prompt":"Use Celsius.","reasoningEffort":null,"receiverThreadIds":["child-thread"],"senderThreadId":"session-1"},"sessionUpdate":"tool_call","status":"in_progress","title":"sendInput","toolCallId":"send-1"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"send-1"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"kind":"other","rawInput":{"agentsStates":{"child-thread":{"message":null,"status":"running"}},"model":null,"prompt":null,"reasoningEffort":null,"receiverThreadIds":["child-thread"],"senderThreadId":"session-1"},"sessionUpdate":"tool_call","status":"in_progress","title":"resumeAgent","toolCallId":"resume-1"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"resume-1"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"kind":"other","rawInput":{"agentsStates":{"child-thread":{"message":null,"status":"running"}},"model":null,"prompt":null,"reasoningEffort":null,"receiverThreadIds":["child-thread"],"senderThreadId":"session-1"},"sessionUpdate":"tool_call","status":"in_progress","title":"closeAgent","toolCallId":"close-1"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"subagent_state_update","state":"completed","subagentSessionId":"child-thread"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"rawInput":{"agentsStates":{"child-thread":{"message":null,"status":"completed"}},"model":null,"prompt":null,"reasoningEffort":null,"receiverThreadIds":["child-thread"],"senderThreadId":"session-1"},"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"close-1"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"session_info_update","title":"Go"}}} +{"direction":"response","method":"session/prompt","params":{"_meta":{"quota":{"model_usage":[],"token_count":null}},"stopReason":"end_turn","usage":null}} diff --git a/src/__tests__/scenarios/data/air/command-approval.jsonl b/src/__tests__/scenarios/data/air/command-approval.jsonl new file mode 100644 index 00000000..9ab3f738 --- /dev/null +++ b/src/__tests__/scenarios/data/air/command-approval.jsonl @@ -0,0 +1,8 @@ +{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"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":"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":"Always ask to edit external files and use the internet","name":"Ask for approval","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Approve for me","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"}],"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Always ask to edit external files and use the internet","id":"read-only","name":"Ask for approval"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Approve for me"},{"_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":{"_meta":{"terminal_info":{"cwd":"/workspace","terminal_id":"cmd-a"}},"content":[{"terminalId":"cmd-a","type":"terminal"}],"kind":"execute","rawInput":{"command":"npm install","cwd":"/workspace"},"sessionUpdate":"tool_call","status":"in_progress","title":"npm install","toolCallId":"cmd-a"}}} +{"direction":"request","method":"session/request_permission","params":{"_meta":{"jetbrains":{"air":{"permission":{"description":"Install the dependencies.","title":"Run command?","version":1},"version":1}}},"options":[{"kind":"allow_once","name":"Yes, proceed","optionId":"allow_once"},{"kind":"allow_always","name":"Yes, and don't ask again for this command in this session","optionId":"allow_for_session"},{"kind":"reject_once","name":"No, continue without running it","optionId":"decline"},{"kind":"reject_once","name":"No, and tell Codex what to do differently","optionId":"cancel"}],"sessionId":"session-1","toolCall":{"rawInput":{"command":"npm install","cwd":"/workspace"},"title":"npm install","toolCallId":"cmd-a"}}} +{"direction":"codexResponse","method":"item/commandExecution/requestApproval","params":{"decision":"accept"}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"_meta":{"terminal_exit":{"exit_code":0,"signal":null,"terminal_id":"cmd-a"},"terminal_output_delta":{"data":"added 1 package\n","terminal_id":"cmd-a"}},"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"cmd-a"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"session_info_update","title":"Go"}}} +{"direction":"response","method":"session/prompt","params":{"_meta":{"quota":{"model_usage":[],"token_count":null}},"stopReason":"end_turn","usage":null}} diff --git a/src/__tests__/scenarios/data/air/command-failed.jsonl b/src/__tests__/scenarios/data/air/command-failed.jsonl new file mode 100644 index 00000000..6bba4f7b --- /dev/null +++ b/src/__tests__/scenarios/data/air/command-failed.jsonl @@ -0,0 +1,7 @@ +{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"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":"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":"Always ask to edit external files and use the internet","name":"Ask for approval","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Approve for me","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"}],"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Always ask to edit external files and use the internet","id":"read-only","name":"Ask for approval"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Approve for me"},{"_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":{"_meta":{"terminal_info":{"cwd":"/workspace","terminal_id":"cmd-3"}},"content":[{"terminalId":"cmd-3","type":"terminal"}],"kind":"execute","rawInput":{"command":"cat missing.txt","cwd":"/workspace"},"sessionUpdate":"tool_call","status":"in_progress","title":"cat missing.txt","toolCallId":"cmd-3"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"_meta":{"terminal_output_delta":{"data":"cat: missing.txt: No such file\n","terminal_id":"cmd-3"}},"sessionUpdate":"tool_call_update","toolCallId":"cmd-3"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"_meta":{"terminal_exit":{"exit_code":1,"signal":null,"terminal_id":"cmd-3"}},"sessionUpdate":"tool_call_update","status":"failed","toolCallId":"cmd-3"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"session_info_update","title":"Go"}}} +{"direction":"response","method":"session/prompt","params":{"_meta":{"quota":{"model_usage":[],"token_count":null}},"stopReason":"end_turn","usage":null}} diff --git a/src/__tests__/scenarios/data/air/command-output-stdin.jsonl b/src/__tests__/scenarios/data/air/command-output-stdin.jsonl new file mode 100644 index 00000000..3196a262 --- /dev/null +++ b/src/__tests__/scenarios/data/air/command-output-stdin.jsonl @@ -0,0 +1,9 @@ +{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"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":"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":"Always ask to edit external files and use the internet","name":"Ask for approval","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Approve for me","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"}],"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Always ask to edit external files and use the internet","id":"read-only","name":"Ask for approval"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Approve for me"},{"_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":{"_meta":{"terminal_info":{"cwd":"/workspace","terminal_id":"cmd-1"}},"content":[{"terminalId":"cmd-1","type":"terminal"}],"kind":"execute","rawInput":{"command":"npm test","cwd":"/workspace"},"sessionUpdate":"tool_call","status":"in_progress","title":"npm test","toolCallId":"cmd-1"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"_meta":{"terminal_output_delta":{"data":"Running tests\n","terminal_id":"cmd-1"}},"sessionUpdate":"tool_call_update","toolCallId":"cmd-1"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"_meta":{"terminal_input":{"data":"y","terminal_id":"cmd-1"}},"sessionUpdate":"tool_call_update","toolCallId":"cmd-1"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"_meta":{"terminal_output_delta":{"data":"1 passed\n","terminal_id":"cmd-1"}},"sessionUpdate":"tool_call_update","toolCallId":"cmd-1"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"_meta":{"terminal_exit":{"exit_code":0,"signal":null,"terminal_id":"cmd-1"}},"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"cmd-1"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"session_info_update","title":"Go"}}} +{"direction":"response","method":"session/prompt","params":{"_meta":{"quota":{"model_usage":[],"token_count":null}},"stopReason":"end_turn","usage":null}} diff --git a/src/__tests__/scenarios/data/air/command-without-streamed-output.jsonl b/src/__tests__/scenarios/data/air/command-without-streamed-output.jsonl new file mode 100644 index 00000000..11c3cc50 --- /dev/null +++ b/src/__tests__/scenarios/data/air/command-without-streamed-output.jsonl @@ -0,0 +1,6 @@ +{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"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":"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":"Always ask to edit external files and use the internet","name":"Ask for approval","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Approve for me","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"}],"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Always ask to edit external files and use the internet","id":"read-only","name":"Ask for approval"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Approve for me"},{"_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":{"_meta":{"terminal_info":{"cwd":"/workspace","terminal_id":"cmd-2"}},"content":[{"terminalId":"cmd-2","type":"terminal"}],"kind":"execute","rawInput":{"command":"ls -la","cwd":"/workspace"},"sessionUpdate":"tool_call","status":"in_progress","title":"ls -la","toolCallId":"cmd-2"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"_meta":{"terminal_exit":{"exit_code":0,"signal":null,"terminal_id":"cmd-2"},"terminal_output_delta":{"data":"a.txt\nb.txt\n","terminal_id":"cmd-2"}},"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"cmd-2"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"session_info_update","title":"Go"}}} +{"direction":"response","method":"session/prompt","params":{"_meta":{"quota":{"model_usage":[],"token_count":null}},"stopReason":"end_turn","usage":null}} diff --git a/src/__tests__/scenarios/data/air/context-compaction.jsonl b/src/__tests__/scenarios/data/air/context-compaction.jsonl new file mode 100644 index 00000000..fcbabdf2 --- /dev/null +++ b/src/__tests__/scenarios/data/air/context-compaction.jsonl @@ -0,0 +1,6 @@ +{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"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":"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":"Always ask to edit external files and use the internet","name":"Ask for approval","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Approve for me","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"}],"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Always ask to edit external files and use the internet","id":"read-only","name":"Ask for approval"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Approve for me"},{"_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":{"_meta":{"jetbrains":{"air":{"contextCompaction":{"version":1},"version":1}}},"kind":"think","sessionUpdate":"tool_call","status":"in_progress","title":"Compact conversation","toolCallId":"compact-1"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"compact-1"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"session_info_update","title":"Go"}}} +{"direction":"response","method":"session/prompt","params":{"_meta":{"quota":{"model_usage":[],"token_count":null}},"stopReason":"end_turn","usage":null}} diff --git a/src/__tests__/scenarios/data/air/dynamic-tool.jsonl b/src/__tests__/scenarios/data/air/dynamic-tool.jsonl new file mode 100644 index 00000000..5174692e --- /dev/null +++ b/src/__tests__/scenarios/data/air/dynamic-tool.jsonl @@ -0,0 +1,6 @@ +{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"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":"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":"Always ask to edit external files and use the internet","name":"Ask for approval","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Approve for me","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"}],"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Always ask to edit external files and use the internet","id":"read-only","name":"Ask for approval"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Approve for me"},{"_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":{"kind":"execute","name":"list_apps","rawInput":{"arguments":{"filter":"all"}},"sessionUpdate":"tool_call","status":"in_progress","title":"list_apps","toolCallId":"dyn-1"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"content":[{"content":{"text":"Found 2 apps","type":"text"},"type":"content"}],"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"dyn-1"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"session_info_update","title":"Go"}}} +{"direction":"response","method":"session/prompt","params":{"_meta":{"quota":{"model_usage":[],"token_count":null}},"stopReason":"end_turn","usage":null}} diff --git a/src/__tests__/scenarios/data/air/file-change-approval.jsonl b/src/__tests__/scenarios/data/air/file-change-approval.jsonl new file mode 100644 index 00000000..4dd77974 --- /dev/null +++ b/src/__tests__/scenarios/data/air/file-change-approval.jsonl @@ -0,0 +1,8 @@ +{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"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":"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":"Always ask to edit external files and use the internet","name":"Ask for approval","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Approve for me","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"}],"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Always ask to edit external files and use the internet","id":"read-only","name":"Ask for approval"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Approve for me"},{"_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":[{"_meta":{"jetbrains":{"air":{"diffPatch":{"format":"git_patch","text":"diff --git a/workspace/src/app.ts b/workspace/src/app.ts\n--- a/workspace/src/app.ts\n+++ b/workspace/src/app.ts\n@@ -1,2 +1,2 @@\n-const a = 1;\n+const a = 2;\n export {a};\n","version":1},"version":1}},"kind":"update"},"newText":"","oldText":null,"path":"/workspace/src/app.ts","type":"diff"}],"kind":"edit","sessionUpdate":"tool_call","status":"in_progress","title":"Editing files","toolCallId":"fc-a"}}} +{"direction":"request","method":"session/request_permission","params":{"_meta":{"jetbrains":{"air":{"permission":{"description":"Update the constant.","title":"Make edits?","version":1},"version":1}}},"options":[{"kind":"allow_once","name":"Yes, proceed","optionId":"allow_once"},{"kind":"allow_always","name":"Yes, and don't ask again for these files","optionId":"allow_for_session"},{"kind":"reject_once","name":"No, and tell Codex what to do differently","optionId":"cancel"}],"sessionId":"session-1","toolCall":{"locations":[{"path":"/workspace/src/app.ts"}],"title":"Editing files","toolCallId":"fc-a"}}} +{"direction":"codexResponse","method":"item/fileChange/requestApproval","params":{"decision":"cancel"}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","status":"failed","toolCallId":"fc-a"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"session_info_update","title":"Go"}}} +{"direction":"response","method":"session/prompt","params":{"_meta":{"quota":{"model_usage":[],"token_count":null}},"stopReason":"end_turn","usage":null}} diff --git a/src/__tests__/scenarios/data/air/file-changes.jsonl b/src/__tests__/scenarios/data/air/file-changes.jsonl new file mode 100644 index 00000000..390aa482 --- /dev/null +++ b/src/__tests__/scenarios/data/air/file-changes.jsonl @@ -0,0 +1,12 @@ +{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"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":"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":"Always ask to edit external files and use the internet","name":"Ask for approval","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Approve for me","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"}],"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Always ask to edit external files and use the internet","id":"read-only","name":"Ask for approval"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Approve for me"},{"_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":[{"_meta":{"jetbrains":{"air":{"diffPatch":{"format":"git_patch","text":"diff --git a/workspace/new.txt b/workspace/new.txt\nnew file mode 100644\n--- /dev/null\n+++ b/workspace/new.txt\n@@ -0,0 +1,2 @@\n+hello\n+world\n","version":1},"version":1}},"kind":"add"},"newText":"","oldText":null,"path":"/workspace/new.txt","type":"diff"}],"kind":"edit","sessionUpdate":"tool_call","status":"in_progress","title":"Editing files","toolCallId":"fc-add"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"fc-add"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"content":[{"_meta":{"jetbrains":{"air":{"diffPatch":{"format":"git_patch","text":"diff --git a/workspace/src/app.ts b/workspace/src/app.ts\n--- a/workspace/src/app.ts\n+++ b/workspace/src/app.ts\n@@ -1,2 +1,2 @@\n-const a = 1;\n+const a = 2;\n export {a};\n","version":1},"version":1}},"kind":"update"},"newText":"","oldText":null,"path":"/workspace/src/app.ts","type":"diff"}],"kind":"edit","sessionUpdate":"tool_call","status":"in_progress","title":"Editing files","toolCallId":"fc-update"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"fc-update"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"content":[{"_meta":{"jetbrains":{"air":{"diffPatch":{"format":"git_patch","text":"diff --git a/workspace/old.txt b/workspace/old.txt\ndeleted file mode 100644\n--- a/workspace/old.txt\n+++ /dev/null\n@@ -1 +0,0 @@\n-bye\n","version":1},"version":1}},"kind":"delete"},"newText":"","oldText":null,"path":"/workspace/old.txt","type":"diff"}],"kind":"edit","sessionUpdate":"tool_call","status":"in_progress","title":"Editing files","toolCallId":"fc-delete"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"fc-delete"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"content":[{"_meta":{"jetbrains":{"air":{"diffPatch":{"format":"git_patch","text":"diff --git a/workspace/before.ts b/workspace/after.ts\nrename from workspace/before.ts\nrename to workspace/after.ts\n--- a/workspace/before.ts\n+++ b/workspace/after.ts\n@@ -1 +1 @@\n-export const name = \"before\";\n+export const name = \"after\";\n","version":1},"version":1}},"kind":"update"},"newText":"","oldText":null,"path":"/workspace/after.ts","type":"diff"}],"kind":"edit","sessionUpdate":"tool_call","status":"in_progress","title":"Editing files","toolCallId":"fc-rename"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","status":"failed","toolCallId":"fc-rename"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"session_info_update","title":"Go"}}} +{"direction":"response","method":"session/prompt","params":{"_meta":{"quota":{"model_usage":[],"token_count":null}},"stopReason":"end_turn","usage":null}} diff --git a/src/__tests__/scenarios/data/air/fuzzy-file-search.jsonl b/src/__tests__/scenarios/data/air/fuzzy-file-search.jsonl new file mode 100644 index 00000000..3cee5734 --- /dev/null +++ b/src/__tests__/scenarios/data/air/fuzzy-file-search.jsonl @@ -0,0 +1,8 @@ +{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"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":"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":"Always ask to edit external files and use the internet","name":"Ask for approval","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Approve for me","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"}],"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Always ask to edit external files and use the internet","id":"read-only","name":"Ask for approval"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Approve for me"},{"_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":{"kind":"search","locations":[{"path":"/workspace/src/Handler.ts"}],"rawInput":{"query":"handler"},"sessionUpdate":"tool_call","status":"in_progress","title":"Search for 'handler'","toolCallId":"fuzzyFileSearch.search-1"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"locations":[{"path":"/workspace/src/OtherHandler.ts"}],"sessionUpdate":"tool_call_update","toolCallId":"fuzzyFileSearch.search-1"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"locations":[],"sessionUpdate":"tool_call_update","title":"Search for 'handlr'","toolCallId":"fuzzyFileSearch.search-1"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"fuzzyFileSearch.search-1"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"session_info_update","title":"Go"}}} +{"direction":"response","method":"session/prompt","params":{"_meta":{"quota":{"model_usage":[],"token_count":null}},"stopReason":"end_turn","usage":null}} diff --git a/src/__tests__/scenarios/data/air/goal-update.jsonl b/src/__tests__/scenarios/data/air/goal-update.jsonl new file mode 100644 index 00000000..5e865649 --- /dev/null +++ b/src/__tests__/scenarios/data/air/goal-update.jsonl @@ -0,0 +1,6 @@ +{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"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":"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":"Always ask to edit external files and use the internet","name":"Ask for approval","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Approve for me","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"}],"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Always ask to edit external files and use the internet","id":"read-only","name":"Ask for approval"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Approve for me"},{"_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":{"_meta":{"jetbrains":{"air":{"goal":{"controlMethod":"_session/goal","createdAt":"