diff --git a/.github/workflows/compiled-process-smoke.yml b/.github/workflows/compiled-process-smoke.yml new file mode 100644 index 000000000..9e7598bb9 --- /dev/null +++ b/.github/workflows/compiled-process-smoke.yml @@ -0,0 +1,29 @@ +name: Compiled POSIX process smoke + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +permissions: {} + +jobs: + compiled-process-smoke: + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: '24' + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 + with: + bun-version: 1.3.11 + - run: npm ci + - name: Compile and exercise native override without Node on child PATH + timeout-minutes: 3 + run: npm run test:compiled-process diff --git a/.github/workflows/native-e2e.yml b/.github/workflows/native-e2e.yml new file mode 100644 index 000000000..d9a8ffd8c --- /dev/null +++ b/.github/workflows/native-e2e.yml @@ -0,0 +1,43 @@ +name: Native loopback E2E + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +permissions: {} + +jobs: + native-e2e: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 10 + permissions: + contents: read + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: '24' + - uses: actions/setup-python@v6 + with: + python-version: '3.13' + - name: Install locked dependencies (including native optional package) + run: npm ci + - name: Fresh build and real native stdio tests with loopback model + timeout-minutes: 5 + run: npm run test:native + - name: Preserve build hashes, RPC, model, spawn and failure evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: native-e2e-${{ matrix.os }} + path: tmp/native-e2e/ + include-hidden-files: true + if-no-files-found: warn + retention-days: 7 diff --git a/.github/workflows/native-queue.yml b/.github/workflows/native-queue.yml new file mode 100644 index 000000000..f1b0459c4 --- /dev/null +++ b/.github/workflows/native-queue.yml @@ -0,0 +1,43 @@ +name: Native queue E2E + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +permissions: {} + +jobs: + native-queue: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 10 + permissions: + contents: read + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: '24' + - uses: actions/setup-python@v6 + with: + python-version: '3.13' + - name: Install locked dependencies (including native optional package) + run: npm ci + - name: Fresh build and real native queue tests with loopback replies + timeout-minutes: 5 + run: npm run test:native-queue + - name: Preserve build hashes, RPC, model, spawn and failure evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: native-queue-${{ matrix.os }} + path: tmp/native-queue/ + include-hidden-files: true + if-no-files-found: warn + retention-days: 7 diff --git a/README.md b/README.md index 0c4ba6678..0b81f02e4 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ Use [OpenAI Codex](https://github.com/openai/codex) from [Agent Client Protocol] ## Features +- Native persistent [message queue controls](docs/native-queue-controls.md), including edit, reorder, cancellation and explicit start. - 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/air-extensions.md#recommended-config-values) capability. @@ -21,6 +22,7 @@ Use [OpenAI Codex](https://github.com/openai/codex) from [Agent Client Protocol] - Typed warnings and errors through the opt-in [AIR session failure extension](docs/air-extensions.md#session-failure). - All AIR extensions, capabilities, and `_meta` keys: [AIR extensions](docs/air-extensions.md). - A per-turn [agent file-change report](docs/air-extensions.md#agent-file-change-report) after capability negotiation. +- In-place message editing through the AIR [session rewind extension](docs/session-rewind-extension.md), without a provider fork. - Client-provided MCP servers over command-based stdio config and HTTP transport. - Slash commands: `/status`, `/mcp` (with `/mcp reconnect`), `/skills`, `/goal`, `/review`, `/review-branch`, `/review-commit`, `/compact`, and `/logout`, as well as configured skills. @@ -107,3 +109,5 @@ See [AIR extensions](docs/air-extensions.md#async-tasks) for the capability, lif ## License By contributing, you agree that your contributions will be licensed under the Apache 2.0 License. + +Local native-session test extensions: [English](docs/native-session-extensions.md) | [简体中文](docs/native-session-extensions.zh-CN.md). diff --git a/docs/native-queue-controls.md b/docs/native-queue-controls.md new file mode 100644 index 000000000..531a72e73 --- /dev/null +++ b/docs/native-queue-controls.md @@ -0,0 +1,107 @@ +# Native persistent queue controls / 原生持久队列 + +## English + +Inspect `initialize._meta.queue.actions`; only individually probed native methods +are advertised. The tested floor is native 0.160.1, not an asserted first release. +All requests use `_session/queue` and a `sessionId`: + +| action | Additional fields | Native result | +| --- | --- | --- | +| list | optional cursor, limit | data, nextCursor | +| add | input, clientUserMessageId | queuedSubmission | +| update | queuedSubmissionId, input | queuedSubmission | +| delete | queuedSubmissionId | deleted | +| reorder | queuedSubmissionIds | empty object | +| start | optional queuedSubmissionId | turn | + +Responses are `{status:"ok",result:...}` or `{status:"unsupported"}`. Native +failures stay errors. `input` uses native UserInput, including `text_elements`; +this method does not convert ACP prompt blocks. `update` preserves the original +client message ID. Reorder supplies every current pending ID exactly once; a +stale set is rejected by native storage. List again after a conflict. + +Queue persistence and scheduling use Codex's original `thread/queue/*` methods. +Adding to a loaded idle thread can start generation immediately. While a turn +runs, additions remain pending. Successful or failed completion advances the +queue. Consumption means accepted for execution, not successful completion. +Explicit start can select a non-head entry; subsequent entries retain their +order and drain automatically. It does not copy the entry into `session/prompt`. + +The adapter installs notifications and interactive delegates before add/start or +native resume. Autonomous turns use fresh event and permission contexts. +Ordinary ACP updates carry output/tools/usage; `_session/queue/turn` carries +`{sessionId,turn}` at start and completion, including native status/error. +`_session/queue/changed` carries `{sessionId}` as an invalidation, not a snapshot +or an acknowledgement that generation has stopped. These notification names +are also exposed in the capability. Hosts must register them before use. + +`session/cancel` interrupts the current owned turn. A native interrupted turn +preserves pending messages and suppresses automatic advancement; use explicit +start to continue. Cancel racing a completed turn is not an atomic pause of the +whole queue. Queue entries can still be edited/reordered/deleted during active +generation. Close keeps the closing fence while interrupting/unsubscribing. +The native unsubscribe operation is not itself a durable pause. + +Unloaded queues can be listed, edited, reordered and deleted without resuming. +Add without an ACP session requires native `notLoaded` status, because an +unsubscribed but still loaded thread can auto-dispatch. Load an idle session +before explicit start. Load/resume may automatically execute a non-interrupted +persisted queue. Ordinary prompts are refused while a pending/active queue is +observed; use the queue start/control operations or explicit steering. +External native writers remain outside the adapter's admission fence. + +Queue mutations are serialized against local lifecycle/settings changes and +bounded at 30 seconds. A timeout invalidates and terminates the provider using +the existing runtime recovery path. An ambiguous mutation failure fences later +queue writes until successful load/reconciliation. Never automatically retry an +add/start: admission and durable queue deletion are not one atomic operation. +No exactly-once execution, independent pause API or arbitrary native RPC tunnel +is promised. Subagent behavior remains subject to existing negotiated support. + +Native source references: `codex-rs/app-server/src/request_processors/thread_queue_processor.rs`, +`codex-rs/ext/queue/src/service.rs`, `codex-rs/state/src/runtime/queued_items.rs` +at `e9e6cf63492da108a3cabaf3cc7b23d0640ef4cf`. Desktop calls the same six queue +operations. Source semantics and executable verification are separate evidence. + +After an app-server crash, queue actions re-probe the replacement server. A session +that was loaded in the dead server requires an explicit `session/load` before queue +mutations; an uncertain write is never replayed. Lazy recovery of an ordinary prompt +refuses to resume a nonempty queue. Explicit load subscribes before native resume and +retains cancellation while history is buffered. Direct session-index rename/archive/ +unarchive requests share the queue reservation and cannot overlap a pending queue +write or cancellation. Existing serialized index writes retain their ordering. + +## 中文 + +客户端先读取 `initialize._meta.queue.actions`,仅调用逐项原生探测通过的动作。 +接口 `_session/queue` 统一带 `sessionId`,参数和返回见上表。新增/编辑采用原生 +UserInput(含 `text_elements`),不把 ACP prompt 格式直接混入。编辑保留原始 +clientUserMessageId;重排必须包含当前全部待发 ID 且各一次,集合陈旧则重读后处理。 + +原生负责持久化和派发,不另建调度器。空闲已加载会话的 add 可能立即生成;生成中 +新增则排队,正常结束或失败后继续下一条。start 可选择非队首消息,该条结束后按 +剩余顺序继续。不能通过重发同一文本模拟 start,否则可能重复执行。 + +ACP 在 resume/add/start 前注册事件和审批处理,自动轮次使用独立生命周期。 +输出/工具/用量沿用普通 session/update;`_session/queue/turn` 通知开始/结束及原生 +turn 状态,`_session/queue/changed` 提醒重读列表。前端应先注册这两个通知。 + +session/cancel 中断当前轮次;原生 interrupted 状态保留剩余消息、暂停自动推进, +显式 start 恢复。取消恰好遇到轮次完成不构成全队列原子暂停,unsubscribe 也不等于 +持久暂停。生成中允许编辑/重排/删除待发项。未加载队列可管理;无 ACP 本地会话时 +add 必须确认原生 notLoaded,避免未订阅但仍已加载的线程自动生成无人处理。 +load/resume 可能恢复非 interrupted 队列并自动执行。 + +app-server 崩溃后重新探测替代进程的能力。旧进程中已加载的会话需显式 +session/load 后再修改队列;普通 prompt 的自动恢复不会隐式派发非空持久队列。 +原生恢复前先订阅,历史缓冲期间仍可取消。sessionIndex 的直接重命名、归档、 +取消归档接口也与队列写入及取消互斥,同时保留原有索引写入串行顺序。 + +30 秒 mutation 超时沿用 provider 失效与终止流程;结果不明确时要求成功 load 对账。 +不自动重试 add/start、不承诺 exactly-once。外部原生客户端的并发写入不受 ACP +本地保护锁控制。此次仅扩展 ACP,Codeg 界面接入和全局安装不在本次改动中。 + +## History and runtime integration + +This queue is included with rewind, file reversal and runtime controls in the same proposal. Queue writes cannot overlap their held reservations. Rewind requires active autonomous work to settle and pending entries to be cleared/completed; it does not discard queued messages implicitly. Unknown history mutation outcomes fence queue writes until explicit load. diff --git a/docs/native-session-extensions.md b/docs/native-session-extensions.md new file mode 100644 index 000000000..81328b024 --- /dev/null +++ b/docs/native-session-extensions.md @@ -0,0 +1,45 @@ +# Local native session extensions + +This branch integrates the same-session rewind contract from [PR #508](https://github.com/agentclientprotocol/codex-acp/pull/508) on upstream `202e66e` (adapter 2.2.2, locked Codex 0.160.1). It preserves upstream custom instructions, session-index subscriptions and app-server recovery, alongside hooks, attachments, tool-call reports and compaction handling. + +## Negotiated operations + +Read the `initialize` metadata before invoking any extension. These are named methods with strict schemas, not a generic native RPC tunnel. + +| Capability | Method | Contract | +|---|---|---| +| AIR `sessionRewind` | `_session/rewind` | Same ID, exclusive `beforeMessage`, optional `resumeAtMessage`; `{rewound:true}` only after persisted-prefix verification. | +| `_meta.steering.idleBehavior` | `_session/steering` | Opt in with `_meta.steering.idleBehavior:"promptRequired"`; idle returns `{outcome:"promptRequired"}` without starting a model request. Non-opted clients retain the existing idle new-turn behavior. | +| `_meta.runtime` | `_session/runtime/read` | `resource`: context, usage, mcp, commands, plugins. Unknown measurements stay null. Commands are native skills; this is not a list of every Desktop UI command. | +| `_meta.runtime` | `_session/runtime/control` | `action`: reloadSkills, reconnectMcp, reloadPlugins. Skill reload republishes available commands. MCP/plugins are provider-wide idle operations; their acknowledgement is not runtime readiness. | +| `_meta.archive` | `_session/archive`, `_session/unarchive` | `{sessionId}`; reversible native archive, same ID. Clients declaring AIR `sessionIndex` use the upstream index implementation, including list notifications and idempotence; other clients retain this extension's archive response. Upstream session/delete behavior, including indexed permanent deletion, is preserved. | +| `_meta.discovery` | `_session/search` | Bounded searchTerm/cursor/limit/archived. Searches user sources appServer/cli/vscode. Current native search can miss text retained only through ancestor rollout lineage after rewind. | +| `_meta.discovery` | `_session/attachments` | list/add/remove native metadata only. Does not create/delete a file, worktree, PR or prompt attachment. Loaded-session ownership required. | +| `_meta.queue` | `_session/queue` | Native persistent list/add/update/delete/reorder/start, each gated by running-native capability probes. Autonomous turns own output, approvals and cancellation; explicit load adopts persisted work before resume. See [queue controls](native-queue-controls.md). | +| `_meta.fileRevert` | `_session/files/revert` | `{sessionId,toolCallId,dryRun,previewToken?}`; reverse one completed native fileChange tool's recorded text patches in Git. Preview token required for apply. History stays unchanged. | + +## File reversal + +Preview and commit re-read native history and fingerprint affected files. A stale preview, conflict, unknown change kind, binary/unsupported patch or unsafe path fails closed for the entire batch. Git uses reverse check/apply without --index/--cached/--3way, preserving the user's index and unrelated bytes. Reverted text follows Git attributes/autocrlf and is not promised to be byte-identical to its original encoding/newlines. Shell changes, unrecorded subagents and whole-workspace checkpoints are outside coverage. + +The server reserves the provider while validating loaded native threads and background terminals. External editors and unrelated processes are not controlled by this fence; Git conflict checks and fresh fingerprints narrow but do not eliminate an OS-level race. + +## Timeouts and recovery + +POSIX supervision uses the current runtime and adds no external Node dependency. Compiled Bun executables re-enter fixed built-in supervisor/guardian modes before importing the ACP application or reading configuration/log settings; these modes require private pipe descriptors and accept no executable source. Node and direct Bun runs execute the same built-in helper implementation. The two small helpers preserve the provider's exit status and perform cleanup from within the owned group; they do not interpret ACP traffic. stdin EOF and stdout/stderr use inherited descriptors, common termination signals are forwarded, and the extra control pipe is unreferenced in the adapter. A POSIX provider spawn error is reported on stderr with supervisor exit code 1. Orphaned processes must be reaped by the host init (use --init in containers); unreaped groups can time out rather than be reported stopped. Compiled native CODEX_PATH launch is tested with Node absent from PATH; this does not establish compatibility of the separate bundled JavaScript launcher path in compiled distributions. + +Runtime mutations have a 30-second deadline. Timeout invalidates local sessions and attempts to terminate the owned provider; reconnect the ACP adapter and explicitly load a session. No mutation is replayed. POSIX launches the provider (including the default Node launcher and native child) in a dedicated process group with a guardian. A private control pipe requests SIGKILL from inside that group; launcher/supervisor exit or adapter pipe closure also triggers cleanup. The adapter never sends a destructive signal to a remembered POSIX PID or PGID. Termination succeeds only after the supervisor exits and the group disappears; an unverifiable group or a five-second deadline is a failure, not a stopped-provider result. Processes that deliberately leave the group are outside this guarantee. Windows retains taskkill /T /F for a live owned launcher; if the launcher has already exited, tree ownership can no longer be verified and termination fails without targeting its old PID. Repeated termination calls share the same result, including failures. Read timeouts return unavailable. A history mutation with an uncertain outcome blocks further sends until successful load/reconciliation. + +## Desktop parity boundaries + +Supported and locally exercised: same-ID first/latest/historical/repeated rewind, cancel-and-settle, cold load, resend context truncation, runtime status/control, archive/unarchive, attachment metadata persistence, safe idle steering, native search of current rollout, queue inspection and per-tool text patch reversal. + +Not claimed: realtime audio/voice, timeline API, complete Desktop project/worktree/UI management, whole-session file checkpoint restore, binary reverse patches, all child process/background writer isolation, or complete search over retained ancestor rollouts. A native schema method does not establish an implemented ACP handler. + +## Validation + +Run `npm run typecheck`, `npm test`, `npm run build`, `npm run bundle:all`, `npm run test:native:all` and `npm run test:compiled-process`. The committed native harness uses isolated homes, workspaces and a loopback model; it checks cold same-session rewind and actual text-patch restoration without user accounts. The refresh also tests crash recovery before extension calls, pending-queue refusal before lazy resume, and session-index mutation exclusion. See the PR validation record for results on its exact source revision; earlier branch test counts do not establish results for this revision. + +## Integrated operation boundaries + +Rewind, file reversal and provider-wide runtime writes share admission with queue mutations. Active/retained autonomous turns must be cancelled and settled; persistent pending entries must be cleared or completed before history/provider changes. A rewind with an unknown outcome also fences subsequent queue writes until explicit load. Ordinary lazy recovery never starts persisted work; explicit load/resume adopts its ownership before native auto-dispatch. All features are delivered in this one integrated proposal, not separate companion PRs. diff --git a/docs/native-session-extensions.zh-CN.md b/docs/native-session-extensions.zh-CN.md new file mode 100644 index 000000000..399812c33 --- /dev/null +++ b/docs/native-session-extensions.zh-CN.md @@ -0,0 +1,37 @@ +# 本地原生会话扩展 + +本分支在上游 `202e66e` 上整合 [PR #508](https://github.com/agentclientprotocol/codex-acp/pull/508) 的同会话回退协议,adapter 2.2.2,冻结 Codex 0.160.1。保留上游自定义指令、会话索引订阅、进程崩溃恢复,以及 hooks、附件回放、工具调用与压缩处理。 + +## 已接入 + +- `_session/rewind`:保留原接口并注明作者来源;同 ID 回退首条、历史、最近消息。验证指纹/保留边界,生成中先取消并等待旧 prompt 清理,再调用原生回退并核验持久前缀。 +- `_session/steering`:opt-in `_meta.steering.idleBehavior:"promptRequired"`,空闲返回 promptRequired,不偷偷启动模型。未 opt-in 保留旧行为。 +- runtime read:context、usage、mcp、commands(native skills)、plugins。未知用量保留 null,不捏造分类。 +- runtime control:reloadSkills、reconnectMcp、reloadPlugins;后两者要求 provider 空闲,确认回执不等于 runtimeReady。技能刷新后重发可用命令。 +- archive/unarchive:原生可逆归档、同 ID。声明 AIR `sessionIndex` 的客户端使用上游索引实现,保留列表通知和幂等性;其它客户端保留本扩展的归档回执。保留上游 session/delete 行为,包括索引客户端的永久删除。 +- search:限定参数和 appServer/cli/vscode 来源;已验证当前 rollout synthetic 文本命中。回退后仅保留在祖先 rollout 的文本可能被原生搜索漏掉。 +- attachments:原生 thread 元数据 list/add/remove、冷重启持久化;不创建/删除磁盘文件、PR 或 worktree。 +- queue:通过运行中原生版本/能力探测后,提供持久 list/add/update/delete/reorder/start;接管自动轮次的输出、审批、取消,显式 load/resume 在恢复前接管待发工作。详见 [队列协议](native-queue-controls.md)。 +- files/revert:限定单个已完成 native fileChange 工具的 Git 文本补丁。preview token 绑定原生补丁与文件指纹,提交前复核;不改会话历史。 + +## 文件语义与恢复 + +文件撤销不等于 whole checkpoint:不覆盖 shell、未记录子代理、二进制补丁。Git reverse check/apply 不用 --index/--cached/--3way;用户 index 和无关文件严格保持。恢复后的文本走 Git 属性/autocrlf,不保证原始字节或换行完全一致。未知 change kind、缺失 diff、冲突、陈旧 token、越界/链接路径整批拒绝。 + +runtime mutation 30 秒超时后使会话失效并尝试终止 provider,用户重连 ACP 再显式 load;不会自动重放 mutation。POSIX 将 provider(包括默认 Node launcher 及 native 子进程)置于独立进程组,通过私有控制管道请求组内 guardian 执行 SIGKILL;launcher/supervisor 退出或适配器管道关闭也触发清理。适配器不会向保存的 POSIX PID/PGID 发破坏性信号。只有 supervisor 已退出且组已消失才报告终止成功;无法确认或五秒超时均报失败。主动脱离该组的进程不在保证内。Windows 对仍存活的 owned launcher 使用 taskkill /T /F;launcher 已退出时无法再确认整树归属,明确报错且不针对旧 PID 清树。重复关闭复用同一结果,包括失败。读取超时返回 unavailable。历史回退 dispatch 后失败可能已改变磁盘,必须成功 load 对账再继续发送。 + +POSIX 监督复用当前运行时,不新增外部 Node 依赖。Bun 编译产物通过固定内置 supervisor/guardian 模式重入,在加载 ACP 模块或读取配置/日志设置前分发;模式要求私有管道描述符,不接收可执行源码。Node 和直接 Bun 运行使用同一份内置 helper 实现。两个小型 helper 分别负责保留 provider 退出状态和从 owned 组内部清理,不解释 ACP 消息;stdin EOF 和 stdout/stderr 使用继承的文件描述符,常规终止信号转发给 provider,额外控制管道在适配器中 unref。POSIX provider 启动失败通过 stderr 和 supervisor 退出码 1 报告。宿主 init 必须回收孤儿进程(容器使用 --init);未回收的组可能超时报错,不虚报已停止。compiled native CODEX_PATH 路径已在 PATH 不含 Node 的条件下验证;这不代表另一个 bundled JavaScript launcher 路径在 compiled 发行物中的兼容性已得到验证。 + +## 尚未实现的 Desktop 能力 + +realtime voice、timeline、Desktop 项目/worktree/UI 全部管理、全工作区文件 checkpoint、二进制反向恢复、外部写入进程完全隔离、祖先 rollout 全文搜索均不宣称完成。native schema 存在某方法不等于本 ACP 已支持。 + +## 验证 + +运行 `npm run typecheck`、`npm test`、`npm run build`、`npm run bundle:all`、`npm run test:native:all` 和 `npm run test:compiled-process`。仓库内原生测试使用隔离 home/workspace 和本地模拟模型,检查同会话冷启动回退及真实文本补丁恢复,不使用用户账号。本次同步还覆盖扩展请求前的崩溃恢复、恢复会话前拒绝待发原生队列,以及会话索引修改互斥。具体提交的结果以 PR 验证记录为准,旧分支测试数字不代表当前修订已通过。 + +协议细节见 [English](native-session-extensions.md);原 rewind 作者 Nikita Ashikhmin,来源 [PR #508](https://github.com/agentclientprotocol/codex-acp/pull/508)。 + +## 整合边界 + +回退、文件恢复、provider 级运行修改与队列写入共享互斥。先取消并等自动轮次结束,再清理或完成持久待发条目,才能改变历史/provider;未知回退结果同样禁止队列写入,直到显式 load 对账。普通自动恢复不会启动待发工作,显式 load/resume 则先接管归属再恢复。所有功能放在同一整合 PR,不再拆分 companion。 diff --git a/docs/session-rewind-extension.md b/docs/session-rewind-extension.md new file mode 100644 index 000000000..19b551de4 --- /dev/null +++ b/docs/session-rewind-extension.md @@ -0,0 +1,77 @@ +# Session rewind extension + +Standard ACP can fork a session, but it cannot remove a transcript suffix from the same provider session. The experimental AIR session rewind extension adds that operation without creating another session. + +## Capability negotiation + +The adapter advertises `sessionRewind` in its `initialize` response: + +```json +{ + "_meta": { + "jetbrains": { + "air": { + "version": 1, + "capabilities": ["sessionRewind"] + } + } + } +} +``` + +A client must send `_session/rewind` only when the adapter advertises this capability. The leading underscore identifies a method outside standard ACP. + +## Request and response + +The request names the current ACP session and the first user message to remove: + +```json +{ + "sessionId": "thread-1", + "beforeMessage": { + "messageId": "user-message-2", + "messageFingerprint": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "messageOccurrence": 1 + }, + "resumeAtMessage": { + "messageId": "assistant-message-1", + "messageFingerprint": "sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + "messageOccurrence": 1 + } +} +``` + +`beforeMessage` is excluded from the retained history. `resumeAtMessage` identifies the last visible assistant message to retain. It is absent when the client rewinds the first user turn. + +Each history point contains the ACP message ID, the SHA-256 fingerprint of its complete text, and the one-based occurrence of that fingerprint for its role. The adapter uses the message ID first and verifies the selected message fingerprint. It uses the fingerprint occurrence when restored provider history has different message IDs. Repeated fingerprint fallback requires a current `resumeAtMessage` boundary; fallback is refused when visible text omits attachment identity. A supplied retained boundary must identify the last retained assistant message. Reload rather than guessing when a point is stale. + +The adapter returns `{ "rewound": true }` only after Codex accepts the rewind and a native history read verifies the same thread ID and retained prefix. Errors after mutation dispatch can have an unknown outcome: the client must load the session to reconcile before retrying or sending. The adapter blocks further prompt/rewind requests until that load succeeds. It never retries a mutation automatically. + +## Codex mapping + +The adapter reads the existing Codex thread history and resolves `beforeMessage` to its containing turn. Rewind is rejected when the selected message is a steer inside an existing turn because Codex cannot remove only that suffix. For paginated history, the adapter calls `thread/revert` with the containing turn as the exclusive boundary; legacy history uses the equivalent turn-count rollback operation. + +The Codex thread ID remains the ACP session ID. The adapter does not call `thread/fork`, create a thread, or add a session-list entry. `resumeAtMessage` validates the retained boundary and disambiguates repeated fingerprint fallback. + +After a successful response, the client can remove the same transcript suffix and place the selected user text in its editor. + + +## Lifecycle and compatibility + +An active prompt is cancelled only after the target validates; the adapter waits for prompt cleanup before native mutation. A pending turn/start registration is rejected, because an unacknowledged native start could write after the rewind. Competing lifecycle/settings/steering/runtime operations are fenced. Provider replacement waits for an existing rewind. + +`thread/rollback` is used only when native history explicitly reports `legacy`. It is a separately typed compatibility request for old `CODEX_PATH` binaries, not part of current generated API types. Unknown history mode, arbitrary errors, malformed responses and `thread/revert` failures never trigger rollback. + +History rewind does not restore files. See [native session extensions](native-session-extensions.md) for the separate, previewed single-tool Git patch reversal. + +The interface and original implementation come from [Nikita Ashikhmin's PR #508](https://github.com/agentclientprotocol/codex-acp/pull/508). This refresh is based on upstream main `202e66e` (2.2.2) and adds validation/lifecycle safeguards; it is not a new incompatible rewind protocol. The historical commits were consolidated during refresh, not retained as a four-commit chain. + +Rewind invalidates pending automatic titles and waits for already dispatched title writes, including work retained from older load/resume/close states. A pending or unacknowledged native title write prevents history mutation. The ordinary bounded load/title wait remains unchanged. Current upstream app-server recovery, custom instructions and sessionIndex behavior are preserved; new index mutations share the rewind admission fence. + +## Native regression tests + +Run `npm run test:native -- --rewind-only` with Node 24+, Python 3 and the locked platform-specific optional Codex package. The harness rebuilds the adapter and runs real native stdio against an isolated loopback model, with fresh homes and workspaces rather than user credentials or sessions. It checks message identities, invalid targets, first/latest/historical rewind, cold reload, resend context and cancellation. + +Build/source/native hashes, RPC and model traces, shutdown evidence and the final report are retained under `tmp/native-e2e/run-*`. Add `--output ` to select another artifact parent. `npm run test:native -- --rewind-only --wrong-reply` deliberately injects an incorrect model response and is expected to exit nonzero; it is a failure-path check, not a normal passing suite. + +Use `npm run test:native:all` for the integrated session-extension and persistent-queue suites. See [native session extensions](native-session-extensions.md#validation) for the complete validation commands and coverage limits. diff --git a/package.json b/package.json index 297f0a972..c429b0d05 100644 --- a/package.json +++ b/package.json @@ -40,9 +40,13 @@ "release:preflight": "bash scripts/release-preflight.sh", "test": "vitest run --no-file-parallelism --retry=2", "test:e2e": "npm run build && RUN_E2E_TESTS=true vitest run --no-file-parallelism --retry=2 src/__tests__/CodexACPAgent/e2e", + "test:native": "node scripts/native-e2e.mjs", + "test:native-queue": "node scripts/native-queue.mjs", + "test:compiled-process": "node scripts/CodexProcessTermination-smoke.mjs", "test:watch": "vitest", "typecheck": "tsc --noEmit && tsc --noEmit -p examples/tsconfig.json", - "codex-test": "tsx .claude/skills/run-codex/scripts/run-codex-test.ts" + "codex-test": "tsx .claude/skills/run-codex/scripts/run-codex-test.ts", + "test:native:all": "npm run test:native && npm run test:native-queue" }, "homepage": "https://github.com/agentclientprotocol/codex-acp#readme", "bugs": { diff --git a/readme-dev.md b/readme-dev.md index 458c263fc..773d7daf7 100644 --- a/readme-dev.md +++ b/readme-dev.md @@ -65,6 +65,78 @@ Run from binaries } ``` +### Reproduce native stdio E2E without credentials + +Requires Node.js 24+, Python 3, Git, and the platform optional dependency installed +by `npm ci`. Run the same command on Windows or Linux: + +```sh +npm ci +npm run test:native +``` + +For a standalone rewind/core change, use `npm run test:native -- --rewind-only`. +In PowerShell, use `npm.cmd run test:native -- --rewind-only` if `npm.ps1` +consumes the argument, or invoke `node scripts/native-e2e.mjs --rewind-only` +directly. `build.json` records `mode: "rewind-only"` so the selected scope is +verifiable. These entry points all rebuild before running. +This runs first/historical/latest rewind, cold restart with no resend, resend +context, stale/bad targets, duplicate identities and cancellation. It asserts +only the rewind capability; it does not call or require file-revert, runtime or +discovery extensions. Build/environment/process checks still apply. The default +suite includes all rewind checks plus the file checks below. + +`test:native` builds the current checkout before each run. It resolves the native +executable from the installed `@openai/codex` platform package, creates a new +fixture under `tmp/native-e2e/run-*`, and uses real ACP/native stdio processes. +The only fake is a scripted Responses model bound to `127.0.0.1`; it returns the +actual advertised `apply_patch` tool call, which the native process executes. +No account, API key, saved user session, or globally installed Codex is needed. + +The suite checks first-message, historical and latest rewind with the same session ID, +cold restart before resend, actual resend model context, duplicate identities, +bad-target non-mutation, active-turn rewind/cancellation, and explicit cancellation. +It also checks native file-change events and cold replay, read-only preview, +required/stale tokens, conflicts, successful patch reversal, preservation of +independent staged/unstaged/untracked files and exact Git index bytes, and +continuation without changing conversation history. A spawn observer records +the runtime native identity, `windowsHide: true`, and child exits without changing +spawn options. The Windows check requires `windowsHide` on runtime children; +the observer follows Node supervisors on Linux too. No client file/tool shim +stands in for native execution. + +All subprocesses receive an environment whitelist with fresh HOME, CODEX_HOME, +profile/config/cache/temp directories and no inherited credentials. Runtime PATH +is narrowed to Node, Git and OS utilities. HTTP(S) proxies deny non-loopback +requests; the provider needs no authorization. This is an application-level +network boundary, not an OS firewall. The fixture uses full-access mode to make +native text patches portable without requiring a host sandbox installation. +Only synthetic fixture paths are provided to its scripted tool calls. + +Each run writes `report.json`, `build.json` (source/build/native hashes), +`rpc-traces.json`, `model-requests.json`, `title-requests.json`, `spawns.jsonl`, native tool outputs and +stderr logs. Failures return nonzero and retain their evidence. Changes to source, +dist, or the native binary during the run fail verification; finish concurrent +edits first and rerun. There is deliberately no resume-old-fixture/skip-build mode. +The Linux/Windows CI matrix uploads these fresh fixture artifacts even on failure. + +Ephemeral title-generation requests are recognized by their output schema and +title instruction, answered locally and recorded separately. They cannot consume +the expected user-turn request budget or satisfy a no-resend/context assertion. + +Optional knobs (no runtime credentials are passed through): + +- `node scripts/native-e2e.mjs --rewind-only --wrong-reply` deliberately changes + the first loopback assistant response. The normal ACP reply assertion must + fail with `Assistant reply mismatch`, `wrongReplyInjected: true` in the report, + and exit code **1**. This is a negative control, not a passing test command; + it does not force an exit or change production behavior. + +- `npm run test:native -- --output ` creates a unique run under that + artifact parent directory; it never reuses an existing run. +- `NATIVE_E2E_PYTHON` selects a Python 3 executable (default `python` on Windows, + `python3` elsewhere). Set it in the invoking shell if Python is not on PATH. + ### Build binaries Building standalone binaries requires [bun](https://bun.com/docs/installation). @@ -105,6 +177,14 @@ 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. +### Isolated native rewind tests + +Run `npm run test:native -- --rewind-only` with Node 24+, Python 3, and the platform +optional Codex package installed. `NATIVE_E2E_PYTHON` overrides the Python +executable (default: `python` on Windows, `python3` elsewhere). The harness +uses an isolated child environment and local provider; it does not reuse your +Codex home or credentials. See [rewind regression tests](docs/session-rewind-extension.md#native-regression-tests) +for assertions, retained evidence and the intentional failure-path command. ### App-server recovery diff --git a/scripts/CodexProcessTermination-smoke.mjs b/scripts/CodexProcessTermination-smoke.mjs new file mode 100644 index 000000000..0c9675297 --- /dev/null +++ b/scripts/CodexProcessTermination-smoke.mjs @@ -0,0 +1,47 @@ +#!/usr/bin/env node +// Linux/macOS: node scripts/CodexProcessTermination-smoke.mjs +// Offline: append --compile-executable-path=/absolute/path/to/bun +// Containers must use --init so killed orphan group members are reaped. +import {spawnSync} from "node:child_process"; +import {mkdtempSync, mkdirSync, rmSync} from "node:fs"; +import {tmpdir} from "node:os"; +import path from "node:path"; +import {fileURLToPath} from "node:url"; + +if (process.platform === "win32") throw new Error("Run this POSIX process-group smoke test on Linux/macOS (containers: --init)"); +const options = process.argv.slice(2); +if (options.length > 1 || options.some(value => !value.startsWith("--compile-executable-path="))) { + throw new Error("Usage: node scripts/CodexProcessTermination-smoke.mjs [--compile-executable-path=/absolute/path/to/bun]"); +} +const root = fileURLToPath(new URL("../", import.meta.url)); +const work = mkdtempSync(path.join(tmpdir(), "CodexProcessTermination-smoke-")); +const noNode = path.join(work, "empty-path"); +mkdirSync(noNode); +// Keep fixture runs isolated from the user's ACP configuration and log settings. +const env = {...process.env, APP_SERVER_LOGS: "", CODEX_CONFIG: "{}", DEFAULT_AUTH_REQUEST: ""}; +function run(command, args, runEnv = env) { + const result = spawnSync(command, args, { + cwd: root, env: runEnv, stdio: "inherit", timeout: 60_000, + killSignal: "SIGKILL", windowsHide: true, + }); + if (result.error) throw result.error; + if (result.status !== 0) throw new Error(`${path.basename(command)} failed: status=${result.status}, signal=${result.signal}`); +} +function compile(source, name) { + const output = path.join(work, name); + // Native host target avoids cross-runtime downloads; an explicit cached Bun + // executable may be supplied for fully offline reproduction. + run("bun", ["build", "--minify", "--compile", ...options, source, "--outfile", output]); + return output; +} +try { + const fixture = compile("src/__tests__/fixtures/CodexProcessTermination.cjs", "native-fixture"); + const transport = compile("src/__tests__/fixtures/CodexProcessTermination.compiled.ts", "transport-smoke"); + const acp = compile("src/index.ts", "codex-acp"); + run(transport, [fixture, "native"], {...env, PATH: noNode}); + run(process.execPath, ["src/__tests__/fixtures/CodexProcessTermination.entrypoint.cjs", acp, fixture], + {...env, PATH: noNode}); + console.log("PASS compiled POSIX smoke: no Node on child PATH; group termination, EOF, timeout and ACP entrypoint verified"); +} finally { + rmSync(work, {recursive: true, force: true}); +} diff --git a/scripts/native-e2e.mjs b/scripts/native-e2e.mjs new file mode 100644 index 000000000..53f7d0764 --- /dev/null +++ b/scripts/native-e2e.mjs @@ -0,0 +1,100 @@ +// No account, external provider, globally installed Codex, or saved fixture is used. +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repo = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const args = process.argv.slice(2); +let output, rewindOnly = false, wrongReply = false; +for (let i = 0; i < args.length; i++) { + if (args[i] === '--rewind-only' && !rewindOnly) rewindOnly = true; + else if (args[i] === '--wrong-reply' && !wrongReply) wrongReply = true; + else if (args[i] === '--output' && !output && args[i + 1] && !args[i + 1].startsWith('--')) output = args[++i]; + else { + console.error('Usage: npm run test:native -- [--rewind-only] [--wrong-reply] [--output ]'); + process.exit(2); + } +} +if (Number(process.versions.node.split('.')[0]) < 24) throw new Error('Node 24+ is required'); +const parent = path.resolve(output ?? path.join(repo, 'tmp', 'native-e2e')); +mkdirSync(parent, { recursive: true }); +const run = mkdtempSync(path.join(parent, 'run-')); +for (const name of ['home', 'workspace', 'tmp', 'home/appdata', 'home/localappdata', 'home/.config', 'home/.cache']) { + mkdirSync(path.join(run, name), { recursive: true }); +} +// A whitelist deliberately excludes credentials, proxies, NODE_OPTIONS, Git +// overrides, Codex configuration, and the invoking user's profile directories. +const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => + ['PATH', 'SYSTEMROOT', 'WINDIR', 'COMSPEC', 'PATHEXT'].includes(key.toUpperCase()))); +Object.assign(env, { + HOME: path.join(run, 'home'), USERPROFILE: path.join(run, 'home'), + CODEX_HOME: path.join(run, 'home'), APPDATA: path.join(run, 'home/appdata'), + LOCALAPPDATA: path.join(run, 'home/localappdata'), + XDG_CONFIG_HOME: path.join(run, 'home/.config'), XDG_CACHE_HOME: path.join(run, 'home/.cache'), + TMP: path.join(run, 'tmp'), TEMP: path.join(run, 'tmp'), TMPDIR: path.join(run, 'tmp'), + GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: path.join(run, 'home/.gitconfig'), + GIT_OPTIONAL_LOCKS: '0', +}); +writeFileSync(env.GIT_CONFIG_GLOBAL, ''); +const hash = (file) => createHash('sha256').update(readFileSync(file)).digest('hex'); +function walk(dir) { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const file = path.join(dir, entry.name); + return entry.isDirectory() ? walk(file) : [file]; + }).sort(); +} +function sources() { + return Object.fromEntries([...walk(path.join(repo, 'src')), + ...['package.json', 'package-lock.json', 'build.mjs'].map((name) => path.join(repo, name))] + .map((file) => [path.relative(repo, file), hash(file)])); +} +function command(binary, argv) { + const result = spawnSync(binary, argv, { + cwd: repo, env, windowsHide: true, encoding: 'utf8', timeout: 120_000, + }); + if (result.error || result.status !== 0) { + throw new Error(`${binary} failed: ${result.error ?? result.stderr ?? result.status}`); + } + return result.stdout.trim(); +} +try { + const require = createRequire(path.join(repo, 'package.json')); + const codexManifest = require.resolve('@openai/codex/package.json'); + const installed = JSON.parse(readFileSync(codexManifest, 'utf8')); + const packageName = `@openai/codex-${process.platform}-${process.arch}`; + if (!installed.optionalDependencies?.[packageName]) throw new Error(`Unsupported native platform: ${packageName}`); + const nativeManifest = createRequire(codexManifest).resolve(`${packageName}/package.json`); + // Discover the actual installed layout; do not assume a vendor triple or bin/codex subdirectory. + const candidates = walk(path.join(path.dirname(nativeManifest), 'vendor')) + .filter((file) => path.basename(file) === (process.platform === 'win32' ? 'codex.exe' : 'codex')); + if (candidates.length !== 1) throw new Error(`Expected one installed native executable, found ${candidates.length}`); + const native = realpathSync(candidates[0]); + const before = sources(); + writeFileSync(path.join(run, 'build.log'), command(process.execPath, [path.join(repo, 'build.mjs')])); + if (JSON.stringify(before) !== JSON.stringify(sources())) throw new Error('Sources changed during build; rerun after concurrent edits finish'); + const metadata = { + repo, run, mode: rewindOnly ? 'rewind-only' : 'full', wrongReply, node: process.execPath, nodeVersion: process.version, native, + nativePackage: packageName, codexVersion: installed.version, + nativeVersion: command(native, ['--version']), nativeSha256: hash(native), + distSha256: hash(path.join(repo, 'dist/index.js')), sources: before, + }; + const manifest = path.join(run, 'build.json'); + writeFileSync(manifest, JSON.stringify(metadata, null, 2) + '\n'); + const python = process.env.NATIVE_E2E_PYTHON || (process.platform === 'win32' ? 'python' : 'python3'); + console.log(`Native E2E artifacts: ${run}`); + const result = spawnSync(python, [path.join(repo, 'scripts/native-e2e/probe.py'), manifest], { + // Python bounds each RPC/child shutdown and cleans up in finally. Killing + // just Python on a launcher timeout would orphan its owned ACP/native tree. + cwd: repo, env, windowsHide: true, stdio: 'inherit', + }); + if (result.error) throw result.error; + process.exitCode = result.status ?? 1; +} catch (error) { + writeFileSync(path.join(run, 'launcher-failure.txt'), String(error.stack ?? error)); + console.error(error); + console.error(`Native E2E artifacts: ${run}`); + process.exitCode = 1; +} diff --git a/scripts/native-e2e/probe.py b/scripts/native-e2e/probe.py new file mode 100644 index 000000000..47fc95d34 --- /dev/null +++ b/scripts/native-e2e/probe.py @@ -0,0 +1,656 @@ +"""Real ACP/native processes over stdio, with a scripted loopback Responses server. + +Only the model is fake. Never read native rollout files, write ACP history, or +execute file tools in the test client. Invoke through ../native-e2e.mjs so every +run is fresh and bound to a just-built artifact. Python 3 standard library only. +""" +import gzip +import hashlib +import http.server +import json +import os +from pathlib import Path +import queue +import shutil +import signal +import subprocess +import sys +import threading +import time +import traceback + + +PATCH = "*** Begin Patch\n*** Update File: changed.txt\n@@\n-old line\n+native edited line\n*** End Patch\n" +CALL_ID = "native-e2e-apply-patch" +TIMEOUT = 35 +FLAGS = getattr(subprocess, "CREATE_NO_WINDOW", 0) + + +def require(condition, message): + # Do not use assert: python -O must still fail a broken native test. + if not condition: + raise AssertionError(message) + + +def sha(file): + return hashlib.sha256(Path(file).read_bytes()).hexdigest() + + +def save(file, value): + Path(file).write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf8") + + +def points(updates, kind="user_message_chunk"): + grouped = {} + for update in updates: + if update.get("sessionUpdate") != kind: + continue + mid = update.get("messageId") + content = update.get("content", {}) + require(mid and content.get("type") == "text", f"Missing native text identity: {update}") + grouped[mid] = grouped.get(mid, "") + content["text"] + seen, result = {}, [] + for mid, text in grouped.items(): + fingerprint = "sha256:" + hashlib.sha256(text.encode()).hexdigest() + seen[fingerprint] = seen.get(fingerprint, 0) + 1 + result.append({"messageId": mid, "messageFingerprint": fingerprint, + "messageOccurrence": seen[fingerprint], "text": text}) + return result + + +def point(value): + return {key: val for key, val in value.items() if key != "text"} + + +class Model: + def __init__(self, wrong_reply=False): + self.requests, self.title_requests, self.errors, self.blocked = [], [], [], [] + self.wrong_reply, self.injected, self.expected_answers = wrong_reply, False, [] + self.pending = queue.Queue() + self.seen, self.release = threading.Event(), threading.Event() + self.lock = threading.Lock() + self.tool_route = None + owner = self + + class Deny(http.server.BaseHTTPRequestHandler): + def log_message(self, *_): + pass + + def reject(self): + owner.blocked.append({"method": self.command, "target": self.path}) + self.send_error(502, "Native E2E denies external HTTP egress") + + do_CONNECT = do_GET = do_POST = reject + + class Responses(http.server.BaseHTTPRequestHandler): + def log_message(self, *_): + pass + + def do_POST(self): + try: + require(self.path == "/v1/responses", f"Unexpected model path: {self.path}") + raw = self.rfile.read(int(self.headers.get("Content-Length", "0"))) + if self.headers.get("Content-Encoding") == "gzip": + raw = gzip.decompress(raw) + body = json.loads(raw) + require("Authorization" not in self.headers, "Unexpected model credentials") + # The adapter also makes ephemeral title turns. Recognize the + # actual schema AND title instruction, never just a model name + # or a sentinel that could appear in discarded user history. + schema = body.get("text", {}).get("format", {}).get("schema", {}) + title = (schema.get("required") == ["title"] + and schema.get("properties") == {"title": {"type": "string"}} + and "Your task is to generate a very short title for a conversation" in json.dumps(body.get("input", []))) + with owner.lock: + target = owner.title_requests if title else owner.requests + target.append({"path": self.path, "body": body}) + number = len(owner.requests) + len(owner.title_requests) + if title: + require(len(owner.title_requests) <= 8, "Unexpected title request loop") + action = "title" + else: + try: + action = owner.pending.get_nowait() + except queue.Empty: + raise AssertionError("Unsolicited model request (rewind/load must not resend)") + if action == "hold": + owner.seen.set() + require(owner.release.wait(TIMEOUT), "Held request was not released") + events = [{"type": "response.created", "response": {"id": f"resp-{number}"}}] + if action == "patch": + found = [] + + def inspect(tools, namespace=None): + for tool in tools: + if tool.get("type") == "namespace": + inspect(tool.get("tools", []), tool.get("name")) + elif tool.get("name") == "apply_patch": + found.append((tool, namespace)) + + inspect(body.get("tools", [])) + require(len(found) == 1 and found[0][0].get("type") == "custom", + "Native must advertise one custom apply_patch tool") + tool, namespace = found[0] + owner.tool_route = {"name": tool["name"], "type": tool["type"], "namespace": namespace} + item = {"type": "custom_tool_call", "id": "fc-" + CALL_ID, "call_id": CALL_ID, + "name": "apply_patch", "input": PATCH} + if namespace: + item["namespace"] = namespace + events.extend([ + {"type": "response.output_item.added", "item": {**item, "input": "", "status": "in_progress"}}, + {"type": "response.custom_tool_call_input.delta", "item_id": item["id"], "call_id": CALL_ID, "delta": PATCH}, + {"type": "response.output_item.done", "item": item}, + ]) + else: + answer = '{"title":"Local native fixture"}' if title else f"LOCAL_ANSWER_{number}" + if not title: + owner.expected_answers.append(answer) + if owner.wrong_reply and not owner.injected: + answer = "INJECTED_WRONG_REPLY" + owner.injected = True + events.append({"type": "response.output_item.done", "item": { + "type": "message", "role": "assistant", "id": f"msg-{number}", + "content": [{"type": "output_text", "text": answer}]}}) + events.append({"type": "response.completed", "response": { + "id": f"resp-{number}", "usage": {"input_tokens": 10, "output_tokens": 2, "total_tokens": 12}}}) + data = "".join("data: " + json.dumps(event) + "\n\n" for event in events).encode() + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + try: + self.wfile.write(data) + except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError): + pass # Expected after cancellation of the held native turn. + except Exception as error: + owner.errors.append(str(error)) + self.send_error(500, "Local fixture failed; see report.json") + + self.proxy = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Deny) + self.server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Responses) + for server in (self.proxy, self.server): + threading.Thread(target=server.serve_forever, daemon=True).start() + + def arm(self, *actions): + require(self.pending.empty(), "Previous prompt did not consume its expected model calls") + for action in actions: + self.pending.put(action) + + def check(self): + require(not self.errors, f"Local model errors: {self.errors}") + require(self.pending.empty(), "Missing expected model call") + + def close(self): + self.release.set() + for server in (self.proxy, self.server): + server.shutdown() + server.server_close() + + +class Rpc: + def __init__(self, probe): + self.probe = probe + self.seq, self.stash, self.trace = 0, {}, [] + self.incoming = queue.Queue() + self.closed = False + self.number = len(probe.clients) + probe.clients.append(self) + self.stderr = (probe.run / f"stderr-{self.number}.log").open("w", encoding="utf8") + self.p = subprocess.Popen( + [probe.meta["node"], str(probe.repo / "dist/index.js")], cwd=probe.work, env=probe.env, + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=self.stderr, + text=True, encoding="utf8", creationflags=FLAGS, start_new_session=os.name != "nt") + self.reader_thread = threading.Thread(target=self.reader, daemon=True) + self.reader_thread.start() + self.init = self.call("initialize", { + "protocolVersion": 1, "clientInfo": {"name": "isolated-native-e2e", "version": "1"}, + "clientCapabilities": {"_meta": {"jetbrains": {"air": { + "version": 1, "capabilities": [] if probe.meta["mode"] == "rewind-only" else ["diffPatch"]}}}}}) + + def reader(self): + try: + for line in self.p.stdout: + try: + message = json.loads(line) + except json.JSONDecodeError: + self.incoming.put(RuntimeError(f"Non-JSON ACP stdout: {line[:200]}")) + continue + self.trace.append({"direction": "in", "message": message}) + self.incoming.put(message) + finally: + self.incoming.put(RuntimeError("ACP stdout closed before requested response")) + + def write(self, message): + self.trace.append({"direction": "out", "message": message}) + self.p.stdin.write(json.dumps(message) + "\n") + self.p.stdin.flush() + + def send(self, method, params): + self.seq += 1 + self.write({"jsonrpc": "2.0", "id": self.seq, "method": method, "params": params}) + return self.seq + + def notify(self, method, params): + self.write({"jsonrpc": "2.0", "method": method, "params": params}) + + def response(self, ident): + if ident in self.stash: + return self.stash.pop(ident) + deadline = time.monotonic() + TIMEOUT + while True: + try: + message = self.incoming.get(timeout=max(0.01, deadline - time.monotonic())) + except queue.Empty: + raise TimeoutError(f"ACP response {ident} timed out; inspect stderr-{self.number}.log") + if isinstance(message, Exception): + raise message + if "id" in message and "method" in message: + # Never emulate native apply_patch or other host tools. + self.write({"jsonrpc": "2.0", "id": message["id"], "error": { + "code": -32601, "message": "Native E2E client tools disabled"}}) + self.probe.client_requests.append(message) + elif "id" in message: + if message["id"] == ident: + return message + self.stash[message["id"]] = message + require(time.monotonic() < deadline, f"ACP response {ident} timed out") + + def raw(self, method, params): + return self.response(self.send(method, params)) + + def call(self, method, params): + message = self.raw(method, params) + require("error" not in message, f"{method}: {message}") + return message["result"] + + def new(self): + return self.call("session/new", {"cwd": str(self.probe.work), "mcpServers": []})["sessionId"] + + def prompt(self, sid, text, patch=False): + self.probe.model.arm(*(["patch", "answer"] if patch else ["answer"])) + result = self.call("session/prompt", {"sessionId": sid, "prompt": [{"type": "text", "text": text}]}) + require(result.get("stopReason") == "end_turn", result) + self.probe.model.check() + # The scripted SSE emits a completed item rather than text deltas. + # Verify the persisted native answer through the real ACP replay route. + answers = points(self.load(sid), "agent_message_chunk") + actual = answers[-1]["text"] if answers else "" + expected = self.probe.model.expected_answers[-1] + require(actual == expected, f"Assistant reply mismatch: expected {expected!r}, received {actual!r}") + return result + + def load(self, sid): + start = len(self.trace) + count = len(self.probe.model.requests) + result = self.call("session/load", {"sessionId": sid, "cwd": str(self.probe.work), "mcpServers": []}) + updates = [entry["message"]["params"] for entry in self.trace[start:] + if entry["direction"] == "in" and entry["message"].get("method") == "session/update"] + require(all(value["sessionId"] == sid for value in updates), "Load changed session ID") + require(result.get("sessionId", sid) == sid, "Load returned another session ID") + require(len(self.probe.model.requests) == count, "Load resent a prompt") + self.probe.model.check() + return [value["update"] for value in updates] + + def rewind(self, sid, before, resume=None): + request = {"sessionId": sid, "beforeMessage": point(before)} + if resume: + request["resumeAtMessage"] = point(resume) + count = len(self.probe.model.requests) + result = self.call("_session/rewind", request) + require(result.get("rewound") is True, result) + require(result.get("sessionId", sid) == sid, result) + require(len(self.probe.model.requests) == count, "Rewind resent a prompt") + return result + + def stop(self): + if self.closed: + return + self.closed = True + forced = False + try: + self.p.stdin.close() + try: + self.p.wait(timeout=8) + except subprocess.TimeoutExpired: + forced = True + if os.name == "nt": + subprocess.run(["taskkill", "/PID", str(self.p.pid), "/T", "/F"], + env=self.probe.env, capture_output=True, timeout=6, creationflags=FLAGS) + else: + os.killpg(self.p.pid, signal.SIGKILL) + self.p.wait(timeout=5) + self.reader_thread.join(timeout=2) + finally: + self.stderr.close() + self.probe.exits.append({"client": self.number, "pid": self.p.pid, + "exitCode": self.p.returncode, "forced": forced}) + require(not forced and self.p.returncode == 0, f"ACP did not shut down cleanly: {self.probe.exits[-1]}") + # Confirm the old provider exited before opening a replacement ACP + # process. A later final check alone could miss overlapping providers. + entries = [json.loads(line) for line in (self.probe.run / "spawns.jsonl").read_text(encoding="utf8").splitlines()] + owned = {self.p.pid} + while True: + children = {entry.get("pid") for entry in entries + if entry["event"] == "spawn" and entry.get("parentPid") in owned} + expanded = owned | (children - {None}) + if expanded == owned: + break + owned = expanded + native = [entry for entry in entries if entry["event"] == "spawn" and entry.get("pid") in owned + and Path(entry["file"]).resolve() == Path(self.probe.meta["native"]).resolve()] + exited = {entry["pid"] for entry in entries if entry["event"] == "exit"} + require(len(native) == 1 and native[0]["pid"] in exited, "Owned native must exit before cold restart") + + +class Probe: + def __init__(self, manifest): + self.meta = json.loads(manifest.read_text(encoding="utf8")) + self.repo, self.run = Path(self.meta["repo"]), Path(self.meta["run"]) + self.home, self.work = self.run / "home", self.run / "workspace" + self.clients, self.exits, self.steps, self.client_requests = [], [], [], [] + self.model = Model(self.meta.get("wrongReply", False)) + self.env = dict(os.environ) # Launcher already replaced, not merged, the user's environment. + # Narrow runtime PATH to required programs and OS utilities. + self.git = shutil.which("git") + require(self.git or self.meta["mode"] == "rewind-only", "Git is required for file revert") + paths = [str(Path(self.meta["node"]).parent)] + if self.git: + paths.append(str(Path(self.git).parent)) + paths += [str(Path(os.environ["SYSTEMROOT"]) / "System32")] if os.name == "nt" else ["/usr/bin", "/bin"] + self.env["PATH"] = os.pathsep.join(dict.fromkeys(paths)) + proxy = f"http://127.0.0.1:{self.model.proxy.server_port}" + for key in ("HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"): + self.env[key] = proxy + self.env.update(NO_PROXY="127.0.0.1,localhost,::1", no_proxy="127.0.0.1,localhost,::1") + provider = {"name": "Native E2E loopback", "base_url": f"http://127.0.0.1:{self.model.server.server_port}/v1", + "wire_api": "responses", "requires_openai_auth": False, + "request_max_retries": 0, "stream_max_retries": 0} + # This installed model profile advertises the freeform native apply_patch. + # The profile name selects tool metadata only; no real model is contacted. + config = {"model": "gpt-5.5", "model_provider": "mock", "model_providers": {"mock": provider}, + "approval_policy": "never", "sandbox_mode": "danger-full-access", + "features": {"shell_snapshot": False, "remote_models": False, "plugins": False, "code_mode": False}} + self.env.update(CODEX_PATH=self.meta["native"], MODEL_PROVIDER="mock", CODEX_CONFIG=json.dumps(config), + INITIAL_AGENT_MODE="agent-full-access", NO_BROWSER="1", + NODE_OPTIONS='--require=' + json.dumps(str(self.repo / "scripts/native-e2e/spawn-audit.cjs")), + NATIVE_E2E_SPAWN_LOG=str(self.run / "spawns.jsonl")) + (self.home / "config.toml").write_text( + 'model = "gpt-5.5"\nmodel_provider = "mock"\napproval_policy = "never"\nsandbox_mode = "danger-full-access"\n' + '[features]\nshell_snapshot = false\nremote_models = false\nplugins = false\ncode_mode = false\n' + '[model_providers.mock]\n' + "\n".join(f"{key} = {json.dumps(value)}" for key, value in provider.items()) + "\n", + encoding="utf8") + self.rpc = None + + def step(self, name, **details): + self.steps.append({"name": name, "passed": True, **details}) + print("PASS " + name, flush=True) + + def start(self): + self.rpc = Rpc(self) + return self.rpc + + def restart(self): + self.rpc.stop() + self.verify_build() + return self.start() + + def verify_build(self): + require(sha(self.repo / "dist/index.js") == self.meta["distSha256"], "Build changed during probe; rerun") + require(sha(self.meta["native"]) == self.meta["nativeSha256"], "Native changed during probe; rerun") + current_names = {str(file.relative_to(self.repo)) for file in (self.repo / "src").rglob("*") if file.is_file()} + current_names.update(["package.json", "package-lock.json", "build.mjs"]) + require(current_names == set(self.meta["sources"]), "Source file set changed during probe; rerun") + for name, expected in self.meta["sources"].items(): + require(sha(self.repo / name) == expected, f"Source changed during probe: {name}; rerun") + + def context(self, present, absent=()): + body = json.dumps(self.model.requests[-1]["body"].get("input", [])) + require(all(text in body for text in present), f"Missing model context: {present}") + require(all(text not in body for text in absent), f"Discarded context sent to model: {absent}") + return body + + def history(self): + c = self.start() + capabilities = c.init.get("_meta", {}).get("jetbrains", {}).get("air", {}).get("capabilities", []) + require("sessionRewind" in capabilities, "AIR rewind capability not advertised") + sid = c.new() + for text in ("KEEP_FIRST", "DROP_SECOND", "DROP_THIRD"): + c.prompt(sid, text) + updates = c.load(sid) + users, agents = points(updates), points(updates, "agent_message_chunk") + require([u["text"] for u in users] == ["KEEP_FIRST", "DROP_SECOND", "DROP_THIRD"], users) + self.step("native_history_identities", sessionId=sid, users=users) + bad = {**point(users[1]), "messageFingerprint": "sha256:" + "0" * 64} + count = len(self.model.requests) + for request in ({"sessionId": sid, "beforeMessage": bad}, + {"sessionId": "unknown-session", "beforeMessage": point(users[1])}, + {"sessionId": sid, "beforeMessage": {**point(users[1]), "messageId": "missing-id", "messageFingerprint": bad["messageFingerprint"]}}): + result = c.raw("_session/rewind", request) + require("error" in result, result) + require(points(c.load(sid)) == users and len(self.model.requests) == count, "Bad target mutated history") + self.step("bad_targets_preserve_history_and_do_not_resend") + c.rewind(sid, users[1], agents[0]) + c = self.restart() + retained = points(c.load(sid)) + require([u["text"] for u in retained] == ["KEEP_FIRST"], retained) + require(len(self.model.requests) == count, "Historical cold restart resent prompt") + self.step("historical_same_id_cold_restart_no_resend", sessionId=sid, retained=retained) + c.prompt(sid, "EDITED_SECOND") + self.context(["KEEP_FIRST", "EDITED_SECOND"], ["DROP_SECOND", "DROP_THIRD"]) + self.step("historical_resend_model_context") + updates = c.load(sid) + users, agents = points(updates), points(updates, "agent_message_chunk") + count = len(self.model.requests) + stale_latest = point(users[-1]) + c.rewind(sid, users[-1], agents[-2]) + c = self.restart() + require([u["text"] for u in points(c.load(sid))] == ["KEEP_FIRST"] and len(self.model.requests) == count, + "Latest rewind/cold restart did not retain exactly the prefix without resend") + self.step("latest_same_id_cold_restart_no_resend", sessionId=sid) + stale = c.raw("_session/rewind", {"sessionId": sid, "beforeMessage": stale_latest}) + require("error" in stale, stale) + require([u["text"] for u in points(c.load(sid))] == ["KEEP_FIRST"] and len(self.model.requests) == count, + "Stale removed target mutated/resubmitted history") + self.step("stale_removed_target_rejected_after_cold_restart") + c.prompt(sid, "EDITED_LATEST") + self.context(["KEEP_FIRST", "EDITED_LATEST"], ["EDITED_SECOND", "DROP_SECOND", "DROP_THIRD"]) + self.step("latest_resend_model_context") + users = points(c.load(sid)) + count = len(self.model.requests) + c.rewind(sid, users[0]) + c = self.restart() + require(points(c.load(sid)) == [] and len(self.model.requests) == count, "First rewind retained/resubmitted history") + self.step("first_same_id_cold_restart_no_resend", sessionId=sid) + c.prompt(sid, "EDITED_FIRST") + self.context(["EDITED_FIRST"], ["KEEP_FIRST", "EDITED_SECOND", "EDITED_LATEST", "DROP_SECOND", "DROP_THIRD"]) + self.step("first_resend_model_context") + target = points(c.load(sid))[0] + self.model.seen.clear() + self.model.release.clear() + self.model.arm("hold") + prompt_id = c.send("session/prompt", {"sessionId": sid, "prompt": [{"type": "text", "text": "ACTIVE_TURN"}]}) + require(self.model.seen.wait(10), "Active prompt did not reach local model") + c.rewind(sid, target) + interrupted = c.response(prompt_id) + require(interrupted.get("result", {}).get("stopReason") == "cancelled", interrupted) + self.model.release.set() + self.model.check() + c = self.restart() + require(points(c.load(sid)) == [], "Busy rewind did not persist empty history") + c.prompt(sid, "AFTER_CANCEL") + self.context(["AFTER_CANCEL"], ["ACTIVE_TURN", "EDITED_FIRST"]) + self.step("active_rewind_cancels_native_turn_and_cold_resumes_same_id") + # Repeated text must use occurrence/native identity, not substring matching. + c.prompt(sid, "REPEATED_TARGET") + c.prompt(sid, "REPEATED_TARGET") + updates = c.load(sid) + users, agents = points(updates), points(updates, "agent_message_chunk") + c.rewind(sid, users[-1], agents[-2]) + c = self.restart() + require([u["text"] for u in points(c.load(sid))] == ["AFTER_CANCEL", "REPEATED_TARGET"], "Repeated target boundary lost") + c.prompt(sid, "REPLACED_DUPLICATE") + require(self.context(["AFTER_CANCEL", "REPEATED_TARGET", "REPLACED_DUPLICATE"]).count("REPEATED_TARGET") == 1, + "Duplicate target survived rewind") + self.step("duplicate_exact_identity_cold_restart_resend") + self.model.seen.clear() + self.model.release.clear() + self.model.arm("hold") + prompt_id = c.send("session/prompt", {"sessionId": sid, "prompt": [{"type": "text", "text": "EXPLICIT_CANCEL"}]}) + require(self.model.seen.wait(10), "Cancel fixture did not reach model") + c.notify("session/cancel", {"sessionId": sid}) + interrupted = c.response(prompt_id) + require(interrupted.get("result", {}).get("stopReason") == "cancelled", interrupted) + self.model.release.set() + c.prompt(sid, "AFTER_EXPLICIT_CANCEL") + self.context(["AFTER_EXPLICIT_CANCEL"]) + self.step("explicit_cancel_and_same_session_continue") + + def git_command(self, *args): + result = subprocess.run([self.git, *args], cwd=self.work, env=self.env, + capture_output=True, timeout=30, creationflags=FLAGS) + require(result.returncode == 0, result.stderr.decode("utf8", errors="replace")) + return result.stdout + + def files(self): + return {name: sha(self.work / name) for name in ("changed.txt", "user.txt", "untracked.txt", ".git/index")} + + def file_revert(self): + self.git_command("init", "-q") + self.git_command("config", "core.autocrlf", "false") + (self.work / "changed.txt").write_bytes(b"old line\n") + (self.work / "user.txt").write_bytes(b"tracked original\n") + self.git_command("add", "--", "changed.txt", "user.txt") + self.git_command("-c", "user.name=Native E2E", "-c", "user.email=fixture@invalid", + "-c", "commit.gpgsign=false", "commit", "-qm", "isolated fixture") + (self.work / "user.txt").write_bytes(b"unrelated staged user content\n") + self.git_command("add", "--", "user.txt") + (self.work / "user.txt").write_bytes(b"unrelated unstaged user content\n") + (self.work / "untracked.txt").write_bytes(b"unrelated untracked user content\n") + initial = self.files() + c = self.rpc + capability = c.init.get("_meta", {}).get("fileRevert", {}) + require(capability.get("method") == "_session/files/revert" and capability.get("previewTokenRequired"), capability) + sid = c.new() + start = len(self.model.requests) + c.prompt(sid, "APPLY_SYNTHETIC_PATCH", patch=True) + require(len(self.model.requests) == start + 2, "Patch must issue tool call then consume native result") + require((self.work / "changed.txt").read_bytes() == b"native edited line\n", "Native patch did not edit bytes") + outputs = [item for item in self.model.requests[-1]["body"].get("input", []) + if item.get("type") == "custom_tool_call_output" and item.get("call_id") == CALL_ID] + require(outputs, "Native apply_patch output did not reach model") + save(self.run / "native-tool-output.json", outputs) + live = [entry["message"]["params"]["update"] for entry in c.trace + if entry["direction"] == "in" and entry["message"].get("method") == "session/update" + and entry["message"]["params"]["sessionId"] == sid] + + def verify_patch_events(updates): + edits = [value for value in updates if value.get("toolCallId") == CALL_ID] + require(any(value.get("kind") == "edit" for value in edits), edits) + require(any(value.get("status") == "completed" for value in edits), edits) + require("changed.txt" in json.dumps(edits), edits) + return edits + + live = verify_patch_events(live) + c = self.restart() + replay = verify_patch_events(c.load(sid)) + save(self.run / "file-change-events.json", {"live": live, "coldReplay": replay}) + self.step("real_apply_patch_bytes_native_output_and_cold_file_change_replay", sessionId=sid, + toolRoute=self.model.tool_route, hashes=self.files()) + request = {"sessionId": sid, "toolCallId": CALL_ID, "dryRun": True} + count = len(self.model.requests) + before = self.files() + preview = c.call("_session/files/revert", request) + require(preview.get("canRevert") and not preview.get("reverted") and preview.get("previewToken"), preview) + require(self.files() == before, "Preview mutated files/index") + missing = c.raw("_session/files/revert", {**request, "dryRun": False}) + require("error" in missing and self.files() == before, missing) + wrong = c.call("_session/files/revert", {**request, "dryRun": False, "previewToken": "sha256:" + "0" * 64}) + require(wrong.get("reason") == "stale_preview" and not wrong.get("reverted") and self.files() == before, wrong) + unknown = c.call("_session/files/revert", {**request, "toolCallId": "unknown-tool"}) + require(unknown.get("reason") == "completed_patch_not_found" and self.files() == before, unknown) + self.step("preview_is_read_only_and_missing_wrong_tokens_and_unknown_tool_refused", preview=preview) + # A deliberate same-line conflict is deterministic on both Git platforms. + # Restore only these fixture-owned bytes after verifying refusal. + (self.work / "changed.txt").write_bytes(b"synthetic conflicting external edit\n") + conflict_before = self.files() + stale = c.call("_session/files/revert", {**request, "dryRun": False, "previewToken": preview["previewToken"]}) + require(stale.get("reason") == "stale_preview" and not stale.get("reverted"), stale) + conflict = c.call("_session/files/revert", request) + require(conflict.get("reason") == "patch_conflict" and not conflict.get("canRevert") and not conflict.get("reverted"), conflict) + require(self.files() == conflict_before, "Conflict/stale token mutated files/index") + self.step("external_edit_invalidates_token_and_conflict_is_non_mutating", stale=stale, conflict=conflict) + (self.work / "changed.txt").write_bytes(b"native edited line\n") + before = self.files() + fresh = c.call("_session/files/revert", request) + require(fresh.get("canRevert") and fresh.get("previewToken") and self.files() == before, fresh) + restored = c.call("_session/files/revert", {**request, "dryRun": False, "previewToken": fresh["previewToken"]}) + require(restored.get("canRevert") and restored.get("reverted"), restored) + require(self.files() == initial, "Restore must recover changed.txt while preserving unrelated files/index bytes") + require(len(self.model.requests) == count, "File preview/revert called the model") + require([u["text"] for u in points(c.load(sid))] == ["APPLY_SYNTHETIC_PATCH"], "File revert changed conversation") + self.step("fresh_token_reverts_only_native_patch_preserving_index_and_independent_files", hashes=self.files()) + c.prompt(sid, "AFTER_FILE_RESTORE") + self.context(["APPLY_SYNTHETIC_PATCH", "AFTER_FILE_RESTORE"]) + require(self.files() == initial, "Continuing changed independent files/index") + self.step("file_restore_same_session_continues_with_conversation_intact") + + def run_all(self): + report = {"success": False, "build": self.meta, "steps": self.steps} + try: + self.verify_build() + self.history() + if self.meta["mode"] != "rewind-only": + self.file_revert() + self.rpc.stop() + self.verify_build() + self.model.check() + require(not self.client_requests, "Native unexpectedly requested host/client tools") + entries = [json.loads(line) for line in (self.run / "spawns.jsonl").read_text(encoding="utf8").splitlines()] + spawns = [entry for entry in entries if entry["event"] == "spawn"] + exits = {entry["pid"] for entry in entries if entry["event"] == "exit"} + native = [entry for entry in spawns if Path(entry["file"]).resolve() == Path(self.meta["native"]).resolve()] + require(len(native) == len(self.clients), "Each ACP process must launch the resolved real native binary once") + require(all(entry.get("pid") in exits for entry in native), "Native child did not exit before cold restart/cleanup") + if os.name == "nt": + require(all(entry.get("windowsHide") is True for entry in spawns), "Runtime spawn missing windowsHide: inspect spawns.jsonl") + require(not any(entry.get("shell") for entry in native), "Direct native executable must not use a shell") + self.step("runtime_native_identity_windowsHide_and_clean_process_shutdown", clients=len(self.clients), spawnCount=len(spawns)) + report["success"] = True + except Exception as error: + report["error"] = {"type": type(error).__name__, "message": str(error)} + (self.run / "failure.txt").write_text(traceback.format_exc(), encoding="utf8") + finally: + self.model.release.set() + for client in self.clients: + try: + client.stop() + except Exception as error: + report["success"] = False + report.setdefault("cleanupErrors", []).append(str(error)) + self.model.close() + try: + self.verify_build() + except Exception as error: + report["success"] = False + report["finalBuildError"] = str(error) + if self.model.errors or not self.model.pending.empty(): + report["success"] = False + report.setdefault("error", {"type": "ModelFixtureError", "message": "Late or missing model request; inspect providerErrors"}) + report.update(modelRequests=len(self.model.requests), titleRequests=len(self.model.title_requests), providerErrors=self.model.errors, + wrongReplyInjected=self.model.injected, + blockedExternalRequests=self.model.blocked, processExits=self.exits, + clientRequests=self.client_requests, + networkBoundary="Loopback provider and deny HTTP(S) proxy; not an OS egress firewall", + harnessSha256=sha(__file__)) + save(self.run / "rpc-traces.json", [client.trace for client in self.clients]) + save(self.run / "model-requests.json", self.model.requests) + save(self.run / "title-requests.json", self.model.title_requests) + save(self.run / "report.json", report) + print(json.dumps({"success": report["success"], "report": str(self.run / "report.json"), + "steps": len(self.steps), "error": report.get("error")}), flush=True) + return 0 if report["success"] else 1 + + +if __name__ == "__main__": + require(len(sys.argv) == 2, "Use npm run test:native (a fresh build manifest is required)") + raise SystemExit(Probe(Path(sys.argv[1])).run_all()) diff --git a/scripts/native-e2e/spawn-audit.cjs b/scripts/native-e2e/spawn-audit.cjs new file mode 100644 index 000000000..c7109e4e9 --- /dev/null +++ b/scripts/native-e2e/spawn-audit.cjs @@ -0,0 +1,13 @@ +// Observe runtime spawn options without changing them or replacing the process. +const cp = require('node:child_process'); +const { appendFileSync } = require('node:fs'); +const { syncBuiltinESMExports } = require('node:module'); +const spawn = cp.spawn; +const record = (entry) => appendFileSync(process.env.NATIVE_E2E_SPAWN_LOG, JSON.stringify(entry) + '\n'); +cp.spawn = function (file, args, options) { + const child = spawn.apply(this, arguments); + record({ event: 'spawn', file, args, parentPid: process.pid, pid: child.pid, windowsHide: options?.windowsHide, shell: options?.shell }); + child.once('exit', (code, signal) => record({ event: 'exit', pid: child.pid, code, signal })); + return child; +}; +syncBuiltinESMExports(); diff --git a/scripts/native-queue.mjs b/scripts/native-queue.mjs new file mode 100644 index 000000000..3533aea9f --- /dev/null +++ b/scripts/native-queue.mjs @@ -0,0 +1,106 @@ +// No account, external provider, globally installed Codex, or saved fixture is used. +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repo = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const args = process.argv.slice(2); +let output, wrongReply = false; +for (let i = 0; i < args.length; i++) { + if (args[i] === '--wrong-reply' && !wrongReply) wrongReply = true; + else if (args[i] === '--output' && !output && args[i + 1] && !args[i + 1].startsWith('--')) output = args[++i]; + else { + console.error('Usage: npm run test:native-queue -- [--wrong-reply] [--output ]'); + process.exit(2); + } +} +if (Number(process.versions.node.split('.')[0]) < 24) throw new Error('Node 24+ is required'); +const parent = path.resolve(output ?? path.join(repo, 'tmp', 'native-queue')); +mkdirSync(parent, { recursive: true }); +const run = mkdtempSync(path.join(parent, 'run-')); +for (const name of ['home', 'workspace', 'tmp', 'home/appdata', 'home/localappdata', 'home/.config', 'home/.cache']) { + mkdirSync(path.join(run, name), { recursive: true }); +} +// A whitelist deliberately excludes credentials, proxies, NODE_OPTIONS, Git +// overrides, Codex configuration, and the invoking user's profile directories. +const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => + ['PATH', 'SYSTEMROOT', 'WINDIR', 'COMSPEC', 'PATHEXT'].includes(key.toUpperCase()))); +Object.assign(env, { + HOME: path.join(run, 'home'), USERPROFILE: path.join(run, 'home'), + CODEX_HOME: path.join(run, 'home'), APPDATA: path.join(run, 'home/appdata'), + LOCALAPPDATA: path.join(run, 'home/localappdata'), + XDG_CONFIG_HOME: path.join(run, 'home/.config'), XDG_CACHE_HOME: path.join(run, 'home/.cache'), + TMP: path.join(run, 'tmp'), TEMP: path.join(run, 'tmp'), TMPDIR: path.join(run, 'tmp'), + GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: path.join(run, 'home/.gitconfig'), + GIT_OPTIONAL_LOCKS: '0', +}); +writeFileSync(env.GIT_CONFIG_GLOBAL, ''); +const hash = (file) => createHash('sha256').update(readFileSync(file)).digest('hex'); +function walk(dir) { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const file = path.join(dir, entry.name); + return entry.isDirectory() ? walk(file) : [file]; + }).sort(); +} +function sources() { + return Object.fromEntries([...walk(path.join(repo, 'src')), + ...walk(path.join(repo, 'scripts/native-queue')), + ...['package.json', 'package-lock.json', 'build.mjs', 'scripts/native-queue.mjs'].map((name) => path.join(repo, name))] + .map((file) => [path.relative(repo, file), hash(file)])); +} +function command(binary, argv) { + const result = spawnSync(binary, argv, { + cwd: repo, env, windowsHide: true, encoding: 'utf8', timeout: 120_000, + }); + if (result.error || result.status !== 0) { + throw new Error(`${binary} failed: ${result.error ?? result.stderr ?? result.status}`); + } + return result.stdout.trim(); +} +try { + const require = createRequire(path.join(repo, 'package.json')); + const codexManifest = require.resolve('@openai/codex/package.json'); + const installed = JSON.parse(readFileSync(codexManifest, 'utf8')); + const version = installed.version.match(/^(\d+)\.(\d+)\.(\d+)$/)?.slice(1).map(Number); + if (!version || !(version[0] > 0 || version[1] > 160 || (version[1] === 160 && version[2] >= 1))) { + throw new Error(`Native queue requires @openai/codex 0.160.1+, found ${installed.version}`); + } + const packageName = `@openai/codex-${process.platform}-${process.arch}`; + if (!installed.optionalDependencies?.[packageName]) throw new Error(`Unsupported native platform: ${packageName}`); + const nativeManifest = createRequire(codexManifest).resolve(`${packageName}/package.json`); + // Discover the actual installed layout; do not assume a vendor triple or bin/codex subdirectory. + const candidates = walk(path.join(path.dirname(nativeManifest), 'vendor')) + .filter((file) => path.basename(file) === (process.platform === 'win32' ? 'codex.exe' : 'codex')); + if (candidates.length !== 1) throw new Error(`Expected one installed native executable, found ${candidates.length}`); + const native = realpathSync(candidates[0]); + const nativeVersion = command(native, ['--version']); + if (nativeVersion !== `codex-cli ${installed.version}`) throw new Error(`Native/package version mismatch: ${nativeVersion}`); + const before = sources(); + writeFileSync(path.join(run, 'build.log'), command(process.execPath, [path.join(repo, 'build.mjs')])); + if (JSON.stringify(before) !== JSON.stringify(sources())) throw new Error('Sources changed during build; rerun after concurrent edits finish'); + const metadata = { + repo, run, wrongReply, node: process.execPath, nodeVersion: process.version, native, + nativePackage: packageName, codexVersion: installed.version, + nativeVersion, nativeSha256: hash(native), + distSha256: hash(path.join(repo, 'dist/index.js')), sources: before, + }; + const manifest = path.join(run, 'build.json'); + writeFileSync(manifest, JSON.stringify(metadata, null, 2) + '\n'); + const python = process.env.NATIVE_E2E_PYTHON || (process.platform === 'win32' ? 'python' : 'python3'); + console.log(`Native queue E2E artifacts: ${run}`); + const result = spawnSync(python, ['-B', path.join(repo, 'scripts/native-queue/probe.py'), manifest], { + // Python bounds each RPC/child shutdown and cleans up in finally. Killing + // just Python on a launcher timeout would orphan its owned ACP/native tree. + cwd: repo, env, windowsHide: true, stdio: 'inherit', + }); + if (result.error) throw result.error; + process.exitCode = result.status ?? 1; +} catch (error) { + writeFileSync(path.join(run, 'launcher-failure.txt'), String(error.stack ?? error)); + console.error(error); + console.error(`Native queue E2E artifacts: ${run}`); + process.exitCode = 1; +} diff --git a/scripts/native-queue/probe.py b/scripts/native-queue/probe.py new file mode 100644 index 000000000..cd62baf59 --- /dev/null +++ b/scripts/native-queue/probe.py @@ -0,0 +1,395 @@ +"""Real ACP/native processes over stdio, with a scripted loopback Responses server. + +Only the model is fake. Never read native rollout files, write ACP history, or +execute file tools in the test client. Invoke through ../native-queue.mjs so every +run is fresh and bound to a just-built artifact. Python 3 standard library only. +""" +import gzip +import hashlib +import http.server +import json +import os +from pathlib import Path +import queue +import shutil +import signal +import subprocess +import sys +import threading +import time +import traceback + + +TIMEOUT = 35 +FLAGS = getattr(subprocess, "CREATE_NO_WINDOW", 0) + + +def require(condition, message): + # Do not use assert: python -O must still fail a broken native test. + if not condition: + raise AssertionError(message) + + +def sha(file): + return hashlib.sha256(Path(file).read_bytes()).hexdigest() + + +def save(file, value): + Path(file).write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf8") + + +class Model: + def __init__(self, wrong_reply=False): + self.requests, self.title_requests, self.errors, self.blocked = [], [], [], [] + self.wrong_reply, self.injected = wrong_reply, False + self.pending = queue.Queue() + self.lock = threading.Lock() + self.gates = [] + owner = self + + class Deny(http.server.BaseHTTPRequestHandler): + def log_message(self, *_): + pass + + def reject(self): + owner.blocked.append({"method": self.command, "target": self.path}) + self.send_error(502, "Native E2E denies external HTTP egress") + + do_CONNECT = do_GET = do_POST = reject + + class Responses(http.server.BaseHTTPRequestHandler): + def log_message(self, *_): + pass + + def do_POST(self): + try: + require(self.path == "/v1/responses", f"Unexpected model path: {self.path}") + raw = self.rfile.read(int(self.headers.get("Content-Length", "0"))) + if self.headers.get("Content-Encoding") == "gzip": + raw = gzip.decompress(raw) + body = json.loads(raw) + require("Authorization" not in self.headers, "Unexpected model credentials") + # The adapter also makes ephemeral title turns. Recognize the + # actual schema AND title instruction, never just a model name + # or a sentinel that could appear in discarded user history. + schema = body.get("text", {}).get("format", {}).get("schema", {}) + title = (schema.get("required") == ["title"] + and schema.get("properties") == {"title": {"type": "string"}} + and "Your task is to generate a very short title for a conversation" in json.dumps(body.get("input", []))) + with owner.lock: + target = owner.title_requests if title else owner.requests + target.append({"path": self.path, "body": body}) + number = len(owner.requests) + len(owner.title_requests) + if title: + require(len(owner.title_requests) <= 8, "Unexpected title request loop") + action = "title" + else: + try: + action = owner.pending.get_nowait() + except queue.Empty: + raise AssertionError("Unsolicited model request (no queue input was armed)") + if isinstance(action, dict): + # Independent gates let a queued successor stay blocked while + # the preceding ordinary prompt completes and cleans up. + users = [item for item in body.get("input", []) if item.get("role") == "user"] + latest = "".join(part.get("text", "") for part in users[-1].get("content", []) + if part.get("type") == "input_text") if users else "" + require(latest == action["input"], + f"Queue input/order mismatch: expected {action['input']!r}, received {latest!r}") + action["seen"].set() + require(action["release"].wait(TIMEOUT), "Queue gate was not released") + events = [{"type": "response.created", "response": {"id": f"resp-{number}"}}] + answer = '{"title":"Local native fixture"}' if title else action["answer"] + if not title and owner.wrong_reply and not owner.injected: + answer = "INJECTED_WRONG_REPLY" + owner.injected = True + if isinstance(action, dict): + events.extend([ + {"type": "response.output_item.added", "item": { + "type": "message", "role": "assistant", "id": f"msg-{number}", "content": []}}, + {"type": "response.output_text.delta", "item_id": f"msg-{number}", + "output_index": 0, "content_index": 0, "delta": answer}, + ]) + events.append({"type": "response.output_item.done", "item": { + "type": "message", "role": "assistant", "id": f"msg-{number}", + "content": [{"type": "output_text", "text": answer}]}}) + events.append({"type": "response.completed", "response": { + "id": f"resp-{number}", "usage": {"input_tokens": 10, "output_tokens": 2, "total_tokens": 12}}}) + data = "".join("data: " + json.dumps(event) + "\n\n" for event in events).encode() + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + try: + self.wfile.write(data) + except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError): + pass # Expected after cancellation of the held native turn. + if isinstance(action, dict): + action["done"].set() + except Exception as error: + owner.errors.append(str(error)) + self.send_error(500, "Local fixture failed; see report.json") + + self.proxy = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Deny) + self.server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Responses) + for server in (self.proxy, self.server): + threading.Thread(target=server.serve_forever, daemon=True).start() + + def arm(self, *actions): + require(self.pending.empty(), "Previous prompt did not consume its expected model calls") + for action in actions: + self.pending.put(action) + + def check(self): + require(not self.errors, f"Local model errors: {self.errors}") + require(self.pending.empty(), "Missing expected model call") + + def close(self): + for gate in self.gates: + gate["release"].set() + for server in (self.proxy, self.server): + server.shutdown() + server.server_close() + + +class Rpc: + def __init__(self, probe): + self.probe = probe + self.seq, self.stash, self.trace = 0, {}, [] + self.incoming = queue.Queue() + self.closed = False + self.number = len(probe.clients) + probe.clients.append(self) + self.stderr = (probe.run / f"stderr-{self.number}.log").open("w", encoding="utf8") + self.p = subprocess.Popen( + [probe.meta["node"], str(probe.repo / "dist/index.js")], cwd=probe.work, env=probe.env, + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=self.stderr, + text=True, encoding="utf8", creationflags=FLAGS, start_new_session=os.name != "nt") + self.reader_thread = threading.Thread(target=self.reader, daemon=True) + self.reader_thread.start() + self.init = self.call("initialize", { + "protocolVersion": 1, "clientInfo": {"name": "isolated-native-queue", "version": "1"}, + "clientCapabilities": {}}) + + def reader(self): + try: + for line in self.p.stdout: + try: + message = json.loads(line) + except json.JSONDecodeError: + self.incoming.put(RuntimeError(f"Non-JSON ACP stdout: {line[:200]}")) + continue + self.trace.append({"direction": "in", "message": message}) + self.incoming.put(message) + finally: + self.incoming.put(RuntimeError("ACP stdout closed before requested response")) + + def write(self, message): + self.trace.append({"direction": "out", "message": message}) + self.p.stdin.write(json.dumps(message) + "\n") + self.p.stdin.flush() + + def send(self, method, params): + self.seq += 1 + self.write({"jsonrpc": "2.0", "id": self.seq, "method": method, "params": params}) + return self.seq + + def notify(self, method, params): + self.write({"jsonrpc": "2.0", "method": method, "params": params}) + + def response(self, ident): + if ident in self.stash: + return self.stash.pop(ident) + deadline = time.monotonic() + TIMEOUT + while True: + try: + message = self.incoming.get(timeout=max(0.01, deadline - time.monotonic())) + except queue.Empty: + raise TimeoutError(f"ACP response {ident} timed out; inspect stderr-{self.number}.log") + if isinstance(message, Exception): + raise message + if "id" in message and "method" in message: + # The queue fixture never requests client tools or approvals. + self.write({"jsonrpc": "2.0", "id": message["id"], "error": { + "code": -32601, "message": "Native E2E client tools disabled"}}) + self.probe.client_requests.append(message) + elif "id" in message: + if message["id"] == ident: + return message + self.stash[message["id"]] = message + require(time.monotonic() < deadline, f"ACP response {ident} timed out") + + def raw(self, method, params): + return self.response(self.send(method, params)) + + def call(self, method, params): + message = self.raw(method, params) + require("error" not in message, f"{method}: {message}") + return message["result"] + + def new(self): + return self.call("session/new", {"cwd": str(self.probe.work), "mcpServers": []})["sessionId"] + + def stop(self): + if self.closed: + return + self.closed = True + forced = False + try: + self.p.stdin.close() + try: + self.p.wait(timeout=8) + except subprocess.TimeoutExpired: + forced = True + if os.name == "nt": + subprocess.run(["taskkill", "/PID", str(self.p.pid), "/T", "/F"], + env=self.probe.env, capture_output=True, timeout=6, creationflags=FLAGS) + else: + os.killpg(self.p.pid, signal.SIGKILL) + self.p.wait(timeout=5) + self.reader_thread.join(timeout=2) + finally: + self.stderr.close() + self.probe.exits.append({"client": self.number, "pid": self.p.pid, + "exitCode": self.p.returncode, "forced": forced}) + require(not forced and self.p.returncode == 0, f"ACP did not shut down cleanly: {self.probe.exits[-1]}") + # Confirm the old provider exited before opening a replacement ACP + # process. A later final check alone could miss overlapping providers. + entries = [json.loads(line) for line in (self.probe.run / "spawns.jsonl").read_text(encoding="utf8").splitlines()] + owned = {self.p.pid} + while True: + children = {entry.get("pid") for entry in entries + if entry["event"] == "spawn" and entry.get("parentPid") in owned} + expanded = owned | (children - {None}) + if expanded == owned: + break + owned = expanded + native = [entry for entry in entries if entry["event"] == "spawn" and entry.get("pid") in owned + and Path(entry["file"]).resolve() == Path(self.probe.meta["native"]).resolve()] + exited = {entry["pid"] for entry in entries if entry["event"] == "exit"} + require(len(native) == 1 and native[0]["pid"] in exited, "Owned native must exit before cold restart") + + +class Probe: + def __init__(self, manifest): + self.meta = json.loads(manifest.read_text(encoding="utf8")) + self.repo, self.run = Path(self.meta["repo"]), Path(self.meta["run"]) + self.home, self.work = self.run / "home", self.run / "workspace" + self.clients, self.exits, self.steps, self.client_requests = [], [], [], [] + self.model = Model(self.meta.get("wrongReply", False)) + self.env = dict(os.environ) # Launcher already replaced, not merged, the user's environment. + # Narrow runtime PATH to required programs and OS utilities. + git = shutil.which("git") + paths = [str(Path(self.meta["node"]).parent)] + if git: + paths.append(str(Path(git).parent)) + paths += [str(Path(os.environ["SYSTEMROOT"]) / "System32")] if os.name == "nt" else ["/usr/bin", "/bin"] + self.env["PATH"] = os.pathsep.join(dict.fromkeys(paths)) + proxy = f"http://127.0.0.1:{self.model.proxy.server_port}" + for key in ("HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"): + self.env[key] = proxy + self.env.update(NO_PROXY="127.0.0.1,localhost,::1", no_proxy="127.0.0.1,localhost,::1") + provider = {"name": "Native E2E loopback", "base_url": f"http://127.0.0.1:{self.model.server.server_port}/v1", + "wire_api": "responses", "requires_openai_auth": False, + "request_max_retries": 0, "stream_max_retries": 0} + # A bundled model profile avoids discovery; all replies come from loopback. + config = {"model": "gpt-5.5", "model_provider": "mock", "model_providers": {"mock": provider}, + "approval_policy": "never", "sandbox_mode": "danger-full-access", + "features": {"shell_snapshot": False, "remote_models": False, "plugins": False, "code_mode": False}} + self.env.update(CODEX_PATH=self.meta["native"], MODEL_PROVIDER="mock", CODEX_CONFIG=json.dumps(config), + INITIAL_AGENT_MODE="agent-full-access", NO_BROWSER="1", + NODE_OPTIONS='--require=' + json.dumps(str(self.repo / "scripts/native-queue/spawn-audit.cjs")), + NATIVE_E2E_SPAWN_LOG=str(self.run / "spawns.jsonl")) + (self.home / "config.toml").write_text( + 'model = "gpt-5.5"\nmodel_provider = "mock"\napproval_policy = "never"\nsandbox_mode = "danger-full-access"\n' + '[features]\nshell_snapshot = false\nremote_models = false\nplugins = false\ncode_mode = false\n' + '[model_providers.mock]\n' + "\n".join(f"{key} = {json.dumps(value)}" for key, value in provider.items()) + "\n", + encoding="utf8") + self.rpc = None + + def step(self, name, **details): + self.steps.append({"name": name, "passed": True, **details}) + print("PASS " + name, flush=True) + + def start(self): + self.rpc = Rpc(self) + return self.rpc + + def restart(self): + self.rpc.stop() + self.verify_build() + return self.start() + + def verify_build(self): + require(sha(self.repo / "dist/index.js") == self.meta["distSha256"], "Build changed during probe; rerun") + require(sha(self.meta["native"]) == self.meta["nativeSha256"], "Native changed during probe; rerun") + current_names = {str(file.relative_to(self.repo)) for file in (self.repo / "src").rglob("*") if file.is_file()} + current_names.update(str(file.relative_to(self.repo)) + for file in (self.repo / "scripts/native-queue").rglob("*") if file.is_file()) + current_names.update(str(Path(name)) for name in + ["package.json", "package-lock.json", "build.mjs", "scripts/native-queue.mjs"]) + require(current_names == set(self.meta["sources"]), "Source file set changed during probe; rerun") + for name, expected in self.meta["sources"].items(): + require(sha(self.repo / name) == expected, f"Source changed during probe: {name}; rerun") + + def run_all(self): + report = {"success": False, "build": self.meta, "steps": self.steps} + try: + self.verify_build() + self.start() + from queue_probe import run_queue_probe + run_queue_probe(self) + self.rpc.stop() + self.verify_build() + self.model.check() + require(not self.client_requests, "Native unexpectedly requested host/client tools") + entries = [json.loads(line) for line in (self.run / "spawns.jsonl").read_text(encoding="utf8").splitlines()] + spawns = [entry for entry in entries if entry["event"] == "spawn"] + exits = {entry["pid"] for entry in entries if entry["event"] == "exit"} + native = [entry for entry in spawns if Path(entry["file"]).resolve() == Path(self.meta["native"]).resolve()] + require(len(native) == len(self.clients), "Each ACP process must launch the resolved real native binary once") + require(all(entry.get("pid") in exits for entry in native), "Native child did not exit before cold restart/cleanup") + self.step("native_identity_and_cold_process_shutdown", clients=len(self.clients), spawnCount=len(spawns)) + report["success"] = True + except Exception as error: + report["error"] = {"type": type(error).__name__, "message": str(error)} + (self.run / "failure.txt").write_text(traceback.format_exc(), encoding="utf8") + finally: + for gate in self.model.gates: + gate["release"].set() + for client in self.clients: + try: + client.stop() + except Exception as error: + report["success"] = False + report.setdefault("cleanupErrors", []).append(str(error)) + self.model.close() + try: + self.verify_build() + except Exception as error: + report["success"] = False + report["finalBuildError"] = str(error) + if self.model.errors or not self.model.pending.empty(): + report["success"] = False + report.setdefault("error", {"type": "ModelFixtureError", "message": "Late or missing model request; inspect providerErrors"}) + report.update(modelRequests=len(self.model.requests), titleRequests=len(self.model.title_requests), providerErrors=self.model.errors, + wrongReplyInjected=self.model.injected, + blockedExternalRequests=self.model.blocked, processExits=self.exits, + clientRequests=self.client_requests, + networkBoundary="Loopback provider and deny HTTP(S) proxy; not an OS egress firewall", + harnessSha256=sha(__file__)) + queue_harness = self.repo / "scripts/native-queue/queue_probe.py" + if queue_harness.exists(): + report["queueHarnessSha256"] = sha(queue_harness) + save(self.run / "rpc-traces.json", [client.trace for client in self.clients]) + save(self.run / "model-requests.json", self.model.requests) + save(self.run / "title-requests.json", self.model.title_requests) + save(self.run / "report.json", report) + print(json.dumps({"success": report["success"], "report": str(self.run / "report.json"), + "steps": len(self.steps), "error": report.get("error")}), flush=True) + return 0 if report["success"] else 1 + + +if __name__ == "__main__": + require(len(sys.argv) == 2, "Use npm run test:native-queue (a fresh build manifest is required)") + raise SystemExit(Probe(Path(sys.argv[1])).run_all()) diff --git a/scripts/native-queue/queue_probe.py b/scripts/native-queue/queue_probe.py new file mode 100644 index 000000000..e4152b2e5 --- /dev/null +++ b/scripts/native-queue/queue_probe.py @@ -0,0 +1,257 @@ +"""Native durable queue checks, invoked by the isolated queue-only probe. + +No native method replacement, synthetic ACP notifications, rollout edits, or +second scheduler. Scripted model responses only; all queue actions cross ACP. +""" +import threading +import time + + +def run_queue_probe(probe): + # probe.py runs as __main__; use its helpers without importing a second copy. + from __main__ import TIMEOUT, require, save + + model, c = probe.model, probe.rpc + actions = ["list", "add", "update", "delete", "reorder", "start"] + capability = c.init.get("_meta", {}).get("queue", {}) + require(capability.get("method") == "_session/queue", capability) + require(capability.get("version") == 1 and capability.get("actions") == actions, capability) + require(capability.get("changedNotification") == "_session/queue/changed" + and capability.get("turnNotification") == "_session/queue/turn", capability) + probe.step("queue_production_capabilities_six_actions", capability=capability) + sid = c.new() + evidence = {"sessionId": sid, "capability": capability} + + def q(action, **params): + response = c.call("_session/queue", {"sessionId": sid, "action": action, **params}) + require(response.get("status") == "ok", response) + return response["result"] + + def text(value): + return [{"type": "text", "text": value, "text_elements": []}] + + def add(value): + item = q("add", input=text(value), clientUserMessageId="client-" + value)["queuedSubmission"] + require(item.get("id") and item.get("clientUserMessageId") == "client-" + value, item) + return item + + def listed(session_id=None): + page = q("list", sessionId=session_id or sid) + require(page.get("nextCursor") is None, page) + return page["data"] + + def gate(value, held=False): + result = {"input": value, "answer": "ANSWER_" + value, + "seen": threading.Event(), "release": threading.Event(), "done": threading.Event()} + if not held: + result["release"].set() + model.gates.append(result) + return result + + def wait(predicate, label, timeout=TIMEOUT): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + require(not model.errors, model.errors) + require(c.p.poll() is None, "ACP exited while waiting for " + label) + if predicate(): + return + time.sleep(0.02) + raise AssertionError("Timed out waiting for " + label) + + def turns(start): + return [entry["message"]["params"]["turn"] for entry in c.trace[start:] + if entry["direction"] == "in" and entry["message"].get("method") == "_session/queue/turn" + and entry["message"]["params"].get("sessionId") == sid] + + def completed(start, count): + wait(lambda: len([t for t in turns(start) if t.get("status") == "completed"]) >= count, + f"{count} completed queue turns") + events = turns(start) + ids = [t["id"] for t in events if t.get("status") == "completed"] + require(len(ids) == count and len(set(ids)) == count, events) + for ident in ids: + require([t["status"] for t in events if t["id"] == ident] == ["inProgress", "completed"], events) + return ids + + def live_answers(start, expected): + chunks = [entry["message"]["params"]["update"].get("content", {}).get("text", "") + for entry in c.trace[start:] if entry["direction"] == "in" + and entry["message"].get("method") == "session/update" + and entry["message"]["params"].get("sessionId") == sid + and entry["message"]["params"]["update"].get("sessionUpdate") == "agent_message_chunk"] + received = "".join(chunks) + require(all(received.count(value) == 1 for value in expected), {"expected": expected, "received": received}) + require([received.index(value) for value in expected] == sorted(received.index(value) for value in expected), received) + + def refused_unchanged(label, action, *, session_id=None, error_code=None, deleted_false=False, **params): + target = session_id or sid + sessions = list(dict.fromkeys([sid, target])) + before = {ident: listed(ident) for ident in sessions} + count, marker = len(model.requests), len(c.trace) + response = c.raw("_session/queue", {"sessionId": target, "action": action, **params}) + if deleted_false: + require("error" not in response and response.get("result", {}).get("status") == "ok" + and response["result"].get("result", {}).get("deleted") is False, {"case": label, "response": response}) + else: + error = response.get("error", {}) + require("result" not in response and error.get("code") in (-32600, -32602), + {"case": label, "response": response}) + if error_code is not None: + require(error["code"] == error_code, {"case": label, "response": response}) + after = {ident: listed(ident) for ident in sessions} + require(after == before, {"case": label, "before": before, "after": after}) + require(len(model.requests) == count and not model.errors, "Rejected operation generated: " + label) + require(not any(entry["direction"] == "in" and entry["message"].get("method") == "_session/queue/turn" + and entry["message"].get("params", {}).get("sessionId") in sessions + for entry in c.trace[marker:]), "Rejected operation emitted a queue turn: " + label) + evidence.setdefault("rejectedOperations", []).append({"case": label, "response": response, "preserved": after}) + + # A brand-new session must own and stream queued work before any ordinary + # prompt installs prompt-local handlers. No session/load replay is involved. + early = gate("QUEUE_EARLY_IDLE") + model.arm(early) + marker = len(c.trace) + add(early["input"]) + evidence["earlyIdleTurns"] = completed(marker, 1) + live_answers(marker, [early["answer"]]) + require(listed() == [], "First idle add did not drain") + require(not any(entry["direction"] == "out" and entry["message"].get("method") == "session/prompt" + for entry in c.trace), "Early idle test ran after an ordinary prompt") + model.check() + probe.step("queue_first_idle_add_streams_before_any_ordinary_prompt", turnIds=evidence["earlyIdleTurns"]) + + # Ordinary prompt owns A; its completion must hand B to the durable owner + # before A's finally block can erase B's state. + initial = gate("QUEUE_INITIAL", held=True) + model.arm(initial) + marker = len(c.trace) + prompt = c.send("session/prompt", {"sessionId": sid, "prompt": text(initial["input"])}) + wait(initial["seen"].is_set, "gated normal prompt") + a, b, removed = add("QUEUE_A"), add("QUEUE_B"), add("QUEUE_DELETE") + changed = q("update", queuedSubmissionId=a["id"], input=text("QUEUE_A_EDITED"))["queuedSubmission"] + require(changed["id"] == a["id"] and changed["clientUserMessageId"] == a["clientUserMessageId"], changed) + require(changed["input"] == text("QUEUE_A_EDITED"), changed) + q("reorder", queuedSubmissionIds=[b["id"], a["id"], removed["id"]]) + require(q("delete", queuedSubmissionId=removed["id"]).get("deleted") is True, "Delete did not remove entry") + require([i["id"] for i in listed()] == [b["id"], a["id"]], "Native reorder/list disagreed") + # The ordinary turn is held, so rejected reorder calls cannot be confused + # with successful consumption. Compare entire records, including order. + refused_unchanged("reorder_missing_ids", "reorder", error_code=-32602) + refused_unchanged("reorder_non_array", "reorder", error_code=-32602, queuedSubmissionIds=b["id"]) + refused_unchanged("reorder_malformed_id", "reorder", error_code=-32602, queuedSubmissionIds=[b["id"], None]) + refused_unchanged("reorder_duplicate_ids", "reorder", queuedSubmissionIds=[b["id"], b["id"]]) + refused_unchanged("reorder_stale_deleted_id", "reorder", queuedSubmissionIds=[b["id"], removed["id"]]) + refused_unchanged("reorder_missing_current_id", "reorder", queuedSubmissionIds=[b["id"]]) + refused_unchanged("reorder_extra_stale_id", "reorder", queuedSubmissionIds=[b["id"], a["id"], removed["id"]]) + probe.step("queue_invalid_reorders_preserve_entries_and_order") + require(not any(t.get("status") == "inProgress" for t in turns(marker)), "Queued work started during gated prompt") + first, second = gate("QUEUE_B", held=True), gate("QUEUE_A_EDITED") + model.arm(first, second) + initial["release"].set() + response = c.response(prompt) + require(response.get("result", {}).get("stopReason") == "end_turn", response) + wait(first["seen"].is_set, "first auto queue turn") + first["release"].set() + evidence["autoTurns"] = completed(marker, 2) + live_answers(marker, [initial["answer"], first["answer"], second["answer"]]) + require(listed() == [], "Auto-dispatched queue did not drain") + require(any(e["direction"] == "in" and e["message"].get("method") == "_session/queue/changed" + and e["message"].get("params", {}).get("sessionId") == sid + for e in c.trace[marker:]), "Missing queue invalidation for this session") + model.check() + probe.step("queue_add_update_reorder_delete_then_auto_dispatch_live_output", turnIds=evidence["autoTurns"]) + + marker = len(c.trace) + blocked = gate("QUEUE_CANCEL", held=True) + model.arm(blocked) + add(blocked["input"]) + wait(blocked["seen"].is_set, "queue turn to cancel") + wait(lambda: any(t.get("status") == "inProgress" for t in turns(marker)), "cancel target native start") + pending_a, pending_b = add("QUEUE_REMAIN_A"), add("QUEUE_REMAIN_B") + c.notify("session/cancel", {"sessionId": sid}) + wait(lambda: any(t.get("status") == "interrupted" for t in turns(marker)), "native interrupted queue notification") + require([t["status"] for t in turns(marker)] == ["inProgress", "interrupted"], turns(marker)) + require(len({t["id"] for t in turns(marker)}) == 1, "Interrupted notification targeted another turn") + require([i["id"] for i in listed()] == [pending_a["id"], pending_b["id"]], "Cancel lost remaining queue") + count = len(model.requests) + blocked["release"].set() + wait(blocked["done"].is_set, "cancelled fixture response cleanup") + # Exercise an idle native start, not the active-turn guard. No model reply + # is armed: a fallback to the head entry must fail this fixture. + preserved = listed() + negative_marker = len(c.trace) + refused_unchanged("start_unknown_id", "start", queuedSubmissionId="missing-native-queue-item") + # A valid second session makes cross-session IDs meaningful. Deleting a + # foreign entry is a native no-op (deleted:false), not a JSON-RPC error. + other_sid = c.new() + require(other_sid != sid and listed(other_sid) == [], "Expected a distinct empty session") + refused_unchanged("update_wrong_session", "update", session_id=other_sid, + queuedSubmissionId=pending_a["id"], input=text("MUST_NOT_REPLACE_PENDING")) + refused_unchanged("delete_wrong_session", "delete", session_id=other_sid, + queuedSubmissionId=pending_a["id"], deleted_false=True) + refused_unchanged("reorder_wrong_session", "reorder", session_id=other_sid, + queuedSubmissionIds=[pending_b["id"], pending_a["id"]]) + refused_unchanged("start_wrong_session", "start", session_id=other_sid, + queuedSubmissionId=pending_a["id"]) + # Span the native external-queue watcher period. No queued model action is + # armed: neither cancel nor a rejected start may permit a delayed restart. + until = time.monotonic() + 10.5 + while time.monotonic() < until: + require(len(model.requests) == count and not model.errors, "Queue advanced after cancel") + require(c.p.poll() is None, "ACP exited during rejected-start observation") + time.sleep(0.05) + require(listed() == preserved and listed(other_sid) == [], "Rejected operations changed a queue after settling") + require(not any(entry["direction"] == "in" and entry["message"].get("method") == "_session/queue/turn" + and entry["message"].get("params", {}).get("sessionId") in (sid, other_sid) + for entry in c.trace[negative_marker:]), "Rejected start emitted a delayed queue turn") + model.check() + probe.step("queue_unknown_start_and_cross_session_ids_preserve_both_queues") + probe.step("queue_cancel_preserves_pending_and_suppresses_automatic_dispatch") + + marker = len(c.trace) + selected, remaining = gate("QUEUE_REMAIN_B"), gate("QUEUE_REMAIN_A") + model.arm(selected, remaining) + start = q("start", queuedSubmissionId=pending_b["id"]) + ids = completed(marker, 2) + require(start["turn"]["id"] == ids[0] and start["turn"]["status"] == "inProgress", start) + live_answers(marker, [selected["answer"], remaining["answer"]]) + require(listed() == [], "Explicit non-head start did not drain remaining queue") + model.check() + probe.step("queue_explicit_non_head_start_then_drain", turnIds=ids) + + # Stop the entire owned ACP/native tree: a fresh client has no local session + # and list/add/update must operate against persisted storage without resume. + # session/close only unsubscribes; it is NOT evidence of native unload. + # restart() waits for and verifies the old native process exit. Never replace + # this barrier with a fixed sleep or an immediate add after session/close. + closed_client = c + c.call("session/close", {"sessionId": sid}) + c = probe.restart() + require(closed_client.p.poll() == 0 and c is not closed_client, + "Cold queue writes require verified previous process termination") + evidence["coldWriteBarrier"] = {"oldClient": closed_client.number, + "newClient": c.number, "oldExitCode": closed_client.p.returncode} + count = len(model.requests) + require(listed() == [], "Expected empty cold queue before persisted add") + cold = add("QUEUE_PERSIST_ORIGINAL") + cold = q("update", queuedSubmissionId=cold["id"], input=text("QUEUE_PERSIST_EDITED"))["queuedSubmission"] + require(cold["clientUserMessageId"] == "client-QUEUE_PERSIST_ORIGINAL", cold) + require(cold["input"] == text("QUEUE_PERSIST_EDITED"), cold) + require(listed() == [cold] and len(model.requests) == count, "Closed-session operations resumed or changed persisted queue") + c = probe.restart() + require(listed() == [cold] and len(model.requests) == count, "Pending queue did not persist across native process restart") + probe.step("queue_closed_session_add_update_list_persist_across_restart", queuedSubmission=cold) + + marker = len(c.trace) + resumed = gate("QUEUE_PERSIST_EDITED") + model.arm(resumed) + # Public reload must install a queue owner and generate the persisted input. + result = c.call("session/load", {"sessionId": sid, "cwd": str(probe.work), "mcpServers": []}) + require(result.get("sessionId", sid) == sid, result) + ids = completed(marker, 1) + live_answers(marker, [resumed["answer"]]) + require(listed() == [], "Reload did not consume persisted native queue") + model.check() + probe.step("queue_reload_installs_owner_and_auto_dispatches_persisted_input", turnIds=ids) + evidence["reloadTurns"] = ids + save(probe.run / "queue-evidence.json", evidence) diff --git a/scripts/native-queue/spawn-audit.cjs b/scripts/native-queue/spawn-audit.cjs new file mode 100644 index 000000000..572248ea3 --- /dev/null +++ b/scripts/native-queue/spawn-audit.cjs @@ -0,0 +1,13 @@ +// Observe real native identity and exit barriers without changing spawned processes. +const cp = require('node:child_process'); +const { appendFileSync } = require('node:fs'); +const { syncBuiltinESMExports } = require('node:module'); +const spawn = cp.spawn; +const record = (entry) => appendFileSync(process.env.NATIVE_E2E_SPAWN_LOG, JSON.stringify(entry) + '\n'); +cp.spawn = function (file, args, options) { + const child = spawn.apply(this, arguments); + record({ event: 'spawn', file, args, parentPid: process.pid, pid: child.pid, windowsHide: options?.windowsHide, shell: options?.shell }); + child.once('exit', (code, signal) => record({ event: 'exit', pid: child.pid, code, signal })); + return child; +}; +syncBuiltinESMExports(); diff --git a/src/AcpExtensions.ts b/src/AcpExtensions.ts index b450c8bd0..95363b81d 100644 --- a/src/AcpExtensions.ts +++ b/src/AcpExtensions.ts @@ -15,6 +15,10 @@ import { ASYNC_TASK_STOP_METHOD, type AsyncTaskStopExtRequest, } from "./async-tasks/AsyncTaskExtension"; +import { + SESSION_REWIND_METHOD, + type SessionRewindRequest, +} from "./SessionRewind"; export { AUTH_STATUS_META_KEY, @@ -79,6 +83,7 @@ export type ExtMethodRequest = | SessionSteeringExtRequest | GoalControlExtRequest | AsyncTaskStopExtRequest + | SessionRewindExtRequest export function isExtMethodRequest(request: { method: string, params: Record }): request is ExtMethodRequest { return request.method === "authentication/status" @@ -87,7 +92,8 @@ export function isExtMethodRequest(request: { method: string, params: Record, params: SessionSteerRequest, diff --git a/src/AirExtension.ts b/src/AirExtension.ts index a121986ea..cb427771a 100644 --- a/src/AirExtension.ts +++ b/src/AirExtension.ts @@ -19,6 +19,7 @@ export const AIR_CUSTOM_INSTRUCTIONS_KEY = "customInstructions"; export const AIR_NATIVE_SUBAGENT_SESSIONS_KEY = "nativeSubagentSessions"; export const AIR_ASYNC_TASKS_KEY = "asyncTasks"; export const AIR_RECOMMENDED_CONFIG_VALUE_KEY = "recommendedValue"; +export const AIR_SESSION_REWIND_KEY = "sessionRewind"; export const AIR_ASYNC_TASKS_BACKGROUNDED_KEY = "backgrounded"; export const AIR_AGENT_FILE_CHANGE_REPORT_REQUEST_KEY = "agentFileChangeReportRequest"; export const AIR_MESSAGE_PHASE_KEY = "phase"; diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index f8fc9380e..95dd64da3 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -1,3 +1,4 @@ +import {probeSessionQueueSupport, type SessionQueueSupport} from "./SessionQueue"; import { type ApiKeyAuthRequest, CODEX_API_KEY_ENV_VAR, @@ -61,8 +62,9 @@ import type {AuthenticationStatusResponse} from "./AcpExtensions"; import {createCodexCollaborationMode} from "./CollaborationModeConfig"; import type {ModeKind} from "./app-server/ModeKind"; import {arePathBasenamesEqual, arePathsEqual, isAbsolutePathLike} from "./PathUtils"; -import {CodexSubagentSubscriptions} from "./subagents/CodexSubagentSubscriptions"; +import {CodexSubagentSubscriptions, type Subscription} from "./subagents/CodexSubagentSubscriptions"; import {forkSession as runForkSession} from "./SessionFork"; +import {rewindSession as runRewindSession, type SessionRewindRequest} from "./SessionRewind"; import type {SessionMetadata, SessionMetadataWithThread} from "./SessionMetadata"; import { isMissingRolloutError, @@ -135,6 +137,21 @@ const SUPPORTED_GATEWAY_PROTOCOLS: Record = { * Converts ACP requests into corresponding app-server operations. */ export class CodexAcpClient { + private readonly promptEventResets = new Map void>(); + resumePromptEvents(sessionId: string): void { this.promptEventResets.get(sessionId)?.(); } + private readonly promptEventReleases = new Map void>(); + finishPromptEvents(sessionId: string): void { this.promptEventReleases.get(sessionId)?.(); } + private readonly queueSubscriptions = new Map>(); + private readonly sessionEventGenerations = new Map(); + + async subscribeToQueueEvents(...subscription: Parameters): Promise { + this.sessionEventGenerations.set(subscription[0], Symbol()); + this.subagents.clear(subscription[0]); + this.queueSubscriptions.set(subscription[0], subscription); + subscription[7] = true; + await this.subscribeToSessionEvents(...subscription); + } + private readonly codexClient: CodexAppServerClient; private readonly config: JsonObject; private readonly modelProvider: string | null; @@ -181,11 +198,13 @@ export class CodexAcpClient { this.gatewayConfig = previous.gatewayConfig; this.gatewayConfigSource = previous.gatewayConfigSource; } + queueSupport: SessionQueueSupport = {nativeVersion: null, actions: []}; private readonly defaultClientInfo: ClientInfo = { name: `${packageJson.name}`, title: "Codex ACP", version: `${packageJson.version}` }; + async initialize(request: acp.InitializeRequest): Promise { const response = await this.codexClient.initialize({ capabilities: { @@ -199,6 +218,9 @@ export class CodexAcpClient { } }); this.configPath = response?.codexHome ?? null; + // The running app-server version, not the ACP client or npm package version. + const version = response?.userAgent?.match(/^[^/]+\/(\d+\.\d+\.\d+)\s+\(/)?.[1]; + this.queueSupport = await probeSessionQueueSupport(version, action => this.codexClient.probeQueueAction(action)); this.isInitialized = true; } @@ -718,6 +740,12 @@ export class CodexAcpClient { }); } + async rewindSession(request: SessionRewindRequest, hooks?: Parameters[2]): Promise<{rewound: boolean}> { + const response = await runRewindSession(request, this.codexClient, hooks); + await this.waitForSessionNotifications(request.sessionId); + return response; + } + async loadSession(request: acp.LoadSessionRequest, onSubscribed?: () => void): Promise { const additionalDirectories = readAdditionalDirectories(request.cwd, request.additionalDirectories, request._meta); await this.refreshSkills(request.cwd, additionalDirectories); @@ -819,14 +847,35 @@ export class CodexAcpClient { } async closeSession(sessionId: string): Promise { + const generation = this.sessionEventGenerations.get(sessionId); try { await this.codexClient.threadUnsubscribe({threadId: sessionId}); } finally { - this.codexClient.clearThreadHandlers(sessionId); - this.subagents.clear(sessionId); + if (this.sessionEventGenerations.get(sessionId) === generation) { + this.sessionEventGenerations.delete(sessionId); + this.queueSubscriptions.delete(sessionId); + this.promptEventResets.delete(sessionId); + this.promptEventReleases.delete(sessionId); + this.codexClient.clearThreadHandlers(sessionId); + this.subagents.clear(sessionId); + } } } + async archiveSession(sessionId: string): Promise { + await this.codexClient.threadArchive({threadId: sessionId}); + } + async unarchiveSession(sessionId: string): Promise { + const response = await this.codexClient.threadUnarchive({threadId: sessionId}); + if (response.thread.id !== sessionId) throw new Error("Unarchive returned a different thread"); + } + async installedPlugins(cwds: string[]) { + return this.codexClient.pluginInstalled({cwds}); + } + async reconcilePlugins() { + return this.codexClient.pluginReconcile({reason: "ACP runtime refresh"}); + } + async deleteSession(sessionId: string): Promise { try { await this.codexClient.threadArchive({threadId: sessionId}); @@ -1101,10 +1150,53 @@ export class CodexAcpClient { supportsSubagents: boolean, observeInteraction: (result: ServerNotification) => void | Promise, waitForChildSession: (childThreadId: string) => Promise, + queueOwner = false, + captureChild?: (childThreadId: string) => Subscription, + observeNativeLifecycle?: (event: ServerNotification) => void, ) { + const generation = this.sessionEventGenerations.get(sessionId) ?? Symbol(); + this.sessionEventGenerations.set(sessionId, generation); + const isCurrent = () => this.sessionEventGenerations.get(sessionId) === generation; + // A prompt may hand off to its queue owner, but never to a later open + // of the same session. Resolve only within this subscription generation. + const queueSubscription = this.queueSubscriptions.get(sessionId); + let promptFinished = queueOwner; + if (!queueOwner) this.promptEventResets.set(sessionId, () => { promptFinished = false; }); + if (!queueOwner) this.promptEventReleases.set(sessionId, () => { promptFinished = true; }); + const current = () => promptFinished ? queueSubscription : undefined; const dispatch = (event: ServerNotification) => { - this.enqueueSessionNotification(sessionId, () => eventHandler(event)); + if (!isCurrent()) return; + (queueSubscription?.[9] ?? observeNativeLifecycle)?.(event); + this.enqueueSessionNotification(sessionId, async () => { + if (!isCurrent()) return; + const owner = event.method === "thread/queue/changed" ? queueSubscription : current(); + await (owner?.[1] ?? eventHandler)(event); + if (event.method === "turn/completed" && event.params.threadId === sessionId) promptFinished = true; + }); }; + const stable: Subscription = { + rootSessionId: sessionId, supportsSubagents, + dispatch: eventHandler, + enqueueInteraction: observeInteraction, + approvalHandler, elicitationHandler, + waitForRootNotifications: () => this.waitForSessionNotifications(sessionId), waitForChildSession, + }; + const deferredSubscription = (captured: Promise): Subscription => ({ + ...stable, + dispatch: event => this.enqueueSessionNotification(sessionId, async () => { const owner = await captured; if (isCurrent()) await owner.dispatch(event); }), + enqueueInteraction: event => this.enqueueSessionNotification(sessionId, async () => { const owner = await captured; if (isCurrent()) await owner.enqueueInteraction(event); }), + approvalHandler: { + handleCommandExecution: async p => { const owner = await captured; return isCurrent() ? owner.approvalHandler.handleCommandExecution(p) : {decision: "cancel"}; }, + handleFileChange: async p => { const owner = await captured; return isCurrent() ? owner.approvalHandler.handleFileChange(p) : {decision: "cancel"}; }, + handlePermissionsRequest: async p => { const owner = await captured; return isCurrent() ? owner.approvalHandler.handlePermissionsRequest(p) : {permissions: {}, scope: "turn", strictAutoReview: false}; }, + }, + elicitationHandler: { + handleElicitation: async p => { const owner = await captured; return isCurrent() ? owner.elicitationHandler.handleElicitation(p) : {action: "cancel", content: null, _meta: null}; }, + handleUserInput: async p => { const owner = await captured; return isCurrent() ? owner.elicitationHandler.handleUserInput(p) : {answers: {}}; }, + }, + waitForChildSession: async id => { const owner = await captured; return isCurrent() ? owner.waitForChildSession(id) : null; }, + captureChild: id => deferredSubscription(captured.then(owner => owner.captureChild?.(id) ?? owner)), + }); this.subagents.subscribe({ rootSessionId: sessionId, supportsSubagents, @@ -1113,12 +1205,25 @@ export class CodexAcpClient { // Child observation uses the same serialized, error-reporting queue // as ordinary session notifications; callers intentionally do not // await the callback registered with app-server. - this.enqueueSessionNotification(sessionId, () => observeInteraction(event)); + this.enqueueSessionNotification(sessionId, () => { if (isCurrent()) return (current()?.[5] ?? observeInteraction)(event); }); + }, + approvalHandler: { + handleCommandExecution: async params => isCurrent() ? (current()?.[2] ?? approvalHandler).handleCommandExecution(params) : {decision: "cancel"}, + handleFileChange: async params => isCurrent() ? (current()?.[2] ?? approvalHandler).handleFileChange(params) : {decision: "cancel"}, + handlePermissionsRequest: async params => isCurrent() ? (current()?.[2] ?? approvalHandler).handlePermissionsRequest(params) : {permissions: {}, scope: "turn", strictAutoReview: false}, + }, + elicitationHandler: { + handleElicitation: async params => isCurrent() ? (current()?.[3] ?? elicitationHandler).handleElicitation(params) : {action: "cancel", content: null, _meta: null}, + handleUserInput: async params => isCurrent() ? (current()?.[3] ?? elicitationHandler).handleUserInput(params) : {answers: {}}, }, - approvalHandler, - elicitationHandler, waitForRootNotifications: () => this.waitForSessionNotifications(sessionId), - waitForChildSession, + waitForChildSession: async id => isCurrent() ? (current()?.[6] ?? waitForChildSession)(id) : null, + captureChild: this.queueSubscriptions.has(sessionId) ? childThreadId => { + let resolve!: (value: Subscription) => void; + const captured = new Promise(r => { resolve = r; }); + this.enqueueSessionNotification(sessionId, () => { resolve(isCurrent() ? current()?.[8]?.(childThreadId) ?? captureChild?.(childThreadId) ?? stable : stable); }); + return deferredSubscription(captured); + } : undefined, }); } diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index d3f90a8bb..57d069240 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -1,7 +1,14 @@ +import {terminateCodexConnection} from "./CodexJsonRpcConnection"; +import {fileRevertCapability, revertSessionFiles, type SessionFileRevertRequest} from "./SessionFileRevert"; +import {parseSessionQueueRequest, runSessionQueue, sessionQueueCapability, type SessionQueueRequest} from "./SessionQueue"; +import {sessionDiscoveryCapability, searchSessions, sessionAttachments, type SessionSearchRequest, type SessionAttachmentsRequest} from "./SessionDiscovery"; +import {archiveCapability} from "./SessionArchive"; import * as acp from "@agentclientprotocol/sdk"; import {RequestError, type SessionId, type SessionModeState} from "@agentclientprotocol/sdk"; import {CodexEventHandler, type CompletedPlan} from "./CodexEventHandler"; -import {attachmentFileUri, desktopAttachmentHistory} from "./DesktopAttachmentHistory"; +import {attachmentFileUri} from "./DesktopAttachmentHistory"; +import {userInputToContentBlocks} from "./UserInputContent"; +import {runtimeCapability, runtimeReadParser, runtimeControlParser, readRuntime, controlRuntime, type RuntimeReadRequest, type RuntimeControlRequest, type RuntimeResponse} from "./SessionRuntime"; import {CodexApprovalHandler} from "./permissions/CodexApprovalHandler"; import {PermissionLifecycleContext} from "./permissions/lifecycle"; import {CodexElicitationHandler} from "./CodexElicitationHandler"; @@ -17,7 +24,8 @@ import { type SessionMetadataWithThread, type UrlElicitationRequester } from "./CodexAcpClient"; -import {CodexAppServerClient} from "./CodexAppServerClient"; +import {CodexAppServerClient, type ApprovalHandler, type ElicitationHandler} from "./CodexAppServerClient"; +import type {Subscription} from "./subagents/CodexSubagentSubscriptions"; import { isAccountReadAccountChangedError, isAccountReadAuthFailureError, @@ -96,6 +104,8 @@ import { type LegacySetSessionModelRequest, type LegacySetSessionModelResponse, SESSION_STEERING_METHOD, + SESSION_REWIND_METHOD, + type SessionRewindRequest, type SessionSteeringResponse, type SessionSteerRequest, } from "./AcpExtensions"; @@ -159,6 +169,7 @@ import { AIR_CUSTOM_INSTRUCTIONS_KEY, AIR_ASYNC_TASKS_KEY, AIR_CODEX_HOOKS_KEY, + AIR_SESSION_REWIND_KEY, AIR_DIFF_PATCH_KEY, AIR_NATIVE_SUBAGENT_SESSIONS_KEY, AIR_PLAN_CONTENT_DELTA_KEY, @@ -333,6 +344,191 @@ export interface CodexProcessState { } export class CodexAcpServer { + private readonly queueCancelGenerations = new Map(); + private readonly queueCancellations = new Map(); + private readonly queueHistoryLoads = new Set(); + private readonly queueOwners = new Map(); + + /** Subscribe before resume/add: both can dispatch without a session/prompt request. */ + private async ownNativeQueue(sessionId: string, state?: SessionState): Promise { + const queueClient = this.codexAcpClient; + const existing = this.queueOwners.get(sessionId); + if (existing) { if (state) existing.install(state); return; } + let install!: (state: SessionState | null) => void; + let installedState = state; + const ready = new Promise(resolve => { install = resolve; }); + let stopped = false; + let cancelled = false; + let nativeTurnId: string | null = null; + const childTurns = new Set(); + const retained = new Set<{abort: AbortController; handler: CodexEventHandler}>(); + let idleHandler: CodexEventHandler | undefined; + const createHandler = (session: SessionState) => new CodexEventHandler(this.connection, session, + clientSupportsTypedSessionFailures(this.clientCapabilities), this.sessionFailureEpoch, + new CodexSubagentEventRouter(sessionId, clientSupportsSubagents(this.clientCapabilities), + new ACPSessionConnection(this.connection, sessionId), id => this.reportingConnection.reports.releaseOpen(id)), + value => this.handleAccountUpdated(value), false, + clientSupportsCompaction(this.clientCapabilities), clientSupportsNotices(this.clientCapabilities)); + let turn: {id: string; abort: AbortController; handler: CodexEventHandler; + approval: ApprovalHandler; elicitation: CodexElicitationHandler; + children: Map; finish(): void}>; + observe(event: ServerNotification): Promise} | undefined; + const owner = { + install: (value: SessionState) => { installedState = value; install(value); }, + stop: () => { + stopped = true; + nativeTurnId = null; + install(null); + turn?.abort.abort(); + for (const childOwner of retained) childOwner.abort.abort(); + void turn?.handler.dispose(); + void idleHandler?.dispose(); + turn = undefined; + }, + cancel: () => { cancelled = true; turn?.abort.abort(); for (const childOwner of retained) childOwner.abort.abort(); }, + allow: () => { cancelled = false; }, + active: () => turn !== undefined || retained.size > 0, + nativeTurnId: () => nativeTurnId, + }; + this.queueOwners.set(sessionId, owner); + const handler = async (event: ServerNotification) => { + const initial = await ready; + const session = installedState ?? initial; + if (!session || stopped) return; + if (event.method === "turn/started" && event.params.threadId === sessionId) { + if (turn?.id === event.params.turn.id) return; + childTurns.clear(); + const abort = new AbortController(); + const context = this.permissionLifecycleContext(session).beginPrompt(); + const renderer = new AcpToolCallRenderer(this.capabilities); + const elicitation = new CodexElicitationHandler(this.connection, context, this.clientCapabilities, abort.signal, renderer); + const events = createHandler(session); + turn = {id: event.params.turn.id, abort, handler: events, + children: new Map(), + approval: new CodexApprovalHandler(this.connection, context, abort.signal, renderer), elicitation, + observe: async value => { context.handleNotification(value); await elicitation.handleNotification(value); }}; + this.beginPromptTokenUsage(session); + if (cancelled || this.sessionIsClosing(sessionId)) { + abort.abort(); + void this.requestTurnInterrupt({threadId: sessionId, turnId: turn.id}, "Cancel").catch(error => { + this.queueRecoveryRequired.add(sessionId); + logger.error("Failed to interrupt a late queued turn", error); + }); + } + } + if (event.method === "thread/queue/changed") { + await this.connection.notify("_session/queue/changed", {sessionId}); + } + const current = turn; + if (!current) { + // Names, goals, account state and background tasks also change between turns. + idleHandler ??= createHandler(session); + await idleHandler.handleSessionScopedNotification(event); + return; + } + if (event.method === "turn/completed" && event.params.threadId === sessionId && event.params.turn.id !== current.id) return; + if (event.method === "turn/started" && event.params.threadId !== sessionId) childTurns.add(event.params.turn.id); + if (event.method === "turn/completed" && event.params.threadId !== sessionId) childTurns.delete(event.params.turn.id); + await current.observe(event); + await current.handler.handleNotification(event); + if (event.method === "turn/started" && event.params.threadId === sessionId) { + await this.connection.notify("_session/queue/turn", {sessionId, turn: event.params.turn}); + } + if (event.method === "turn/completed" && event.params.threadId === sessionId && event.params.turn.id === current.id) { + try { + await current.handler.flushPendingErrors(); + await current.handler.handleFailedTurn(event.params.turn); + await current.handler.flushPendingPlanUpdates(); + await this.connection.notify("_session/queue/turn", {sessionId, turn: event.params.turn}); + } finally { + if (turn === current) turn = undefined; + retained.add(current); + // Child terminal notifications must keep flowing; waiting inside + // this serialized callback would deadlock their completion. + void (async () => { + try { + if (event.params.turn.status === "completed") { + await current.handler.waitForNativeSubagents(current.abort.signal); + let onAbort!: () => void; + const cancelledChildren = new Promise(resolve => { onAbort = resolve; current.abort.signal.addEventListener("abort", onAbort, {once: true}); }); + try { + const deadline = Date.now() + 600_000; + while (!current.abort.signal.aborted && current.children.size > 0) { + const remaining = deadline - Date.now(); + if (remaining <= 0) break; + // Descendants can be registered while a child is running. + const changed = Promise.race([...current.children.values()].map(child => child.done)); + if (await settledWithin(Promise.race([changed, cancelledChildren]), remaining) === "pending") break; + } + } finally { current.abort.signal.removeEventListener("abort", onAbort); } + } + else await current.handler.finishOutstandingNativeSubagents(event.params.turn.status === "interrupted" ? "cancelled" : "failed"); + } finally { + current.abort.abort(); + await current.handler.dispose(); + retained.delete(current); + } + })().catch(error => logger.error("Queued child cleanup failed", error)); + } + } + }; + const owns = (p: {threadId: string; turnId?: string | null}) => turn && !turn.abort.signal.aborted + && (p.turnId == null || p.turnId === turn.id || childTurns.has(p.turnId)); + const captureChild = (childThreadId: string, captured = turn): Subscription => { + let finish!: () => void; + const done = new Promise(resolve => { finish = resolve; }); + captured?.children.set(childThreadId, {done, finish}); + const finishChild = (event: ServerNotification) => { + if (event.method === "turn/completed" && event.params.threadId === childThreadId) { + captured?.children.delete(childThreadId); + finish(); + } + }; + return { + rootSessionId: sessionId, supportsSubagents: clientSupportsSubagents(this.clientCapabilities), + dispatch: async event => { if (captured) { await captured.observe(event); await captured.handler.handleNotification(event); finishChild(event); } }, + enqueueInteraction: async event => { await captured?.observe(event); finishChild(event); }, + approvalHandler: captured?.approval ?? { + handleCommandExecution: async () => ({decision: "cancel"}), + handleFileChange: async () => ({decision: "cancel"}), + handlePermissionsRequest: async () => ({permissions: {}, scope: "turn", strictAutoReview: false}), + }, + elicitationHandler: captured?.elicitation ?? { + handleElicitation: async () => ({action: "cancel", content: null, _meta: null}), + handleUserInput: async () => ({answers: {}}), + }, + waitForRootNotifications: () => queueClient.waitForSessionNotifications(sessionId), + waitForChildSession: async id => await captured?.handler.waitForNativeSubagentSession(id) ?? null, + captureChild: id => captureChild(id, captured), + }; + }; + await queueClient.subscribeToQueueEvents(sessionId, handler, { + handleCommandExecution: async p => owns(p) ? turn!.approval.handleCommandExecution(p) : {decision: "cancel"}, + handleFileChange: async p => owns(p) ? turn!.approval.handleFileChange(p) : {decision: "cancel"}, + handlePermissionsRequest: async p => owns(p) ? turn!.approval.handlePermissionsRequest(p) : {permissions: {}, scope: "turn", strictAutoReview: false}, + }, { + handleElicitation: async p => owns(p) ? turn!.elicitation.handleElicitation(p) : {action: "cancel", content: null, _meta: null}, + handleUserInput: async p => owns(p) ? turn!.elicitation.handleUserInput(p) : {answers: {}}, + }, clientSupportsSubagents(this.clientCapabilities), async event => { + if (event.method === "turn/started") childTurns.add(event.params.turn.id); + if (event.method === "turn/completed") childTurns.delete(event.params.turn.id); + await turn?.observe(event); + }, + async id => await turn?.handler.waitForNativeSubagentSession(id) ?? null, true, captureChild, + event => { + if (stopped) return; + if (event.method === "turn/started" && event.params.threadId === sessionId) nativeTurnId = event.params.turn.id; + if (event.method === "turn/completed" && event.params.threadId === sessionId && event.params.turn.id === nativeTurnId) nativeTurnId = null; + }); + if (state) install(state); + } private codexAcpClient: CodexAcpClient; private readonly connection: AcpClientConnection; private readonly mcpServerSignIn: McpServerSignIn; @@ -375,6 +571,16 @@ export class CodexAcpServer { private readonly recovery: AppServerRecovery | null; private initializeRequest: acp.InitializeRequest | null = null; private providerUpdate: Promise | null = null; + private providerRuntimeControl = false; + private providerCreationReservations = 0; + private providerInvalidated = false; + private providerReads = 0; + private readonly runtimeControls = new Set(); + private readonly rewinds = new Map>(); + private readonly rewindRecoveryRequired = new Set(); + /** Old SessionStates may still own asynchronous title work for the same native thread. */ + private readonly retiredTitleGenerators = new Map>(); + private readonly sessionOperations = new Map(); /** The AIR `sessionIndex` extension, see `SessionIndexService.ts`. */ readonly sessionIndex = new SessionIndexService({ connection: () => this.connection, @@ -390,7 +596,17 @@ export class CodexAcpServer { }, beginSessionCloseFence: (sessionId) => this.beginSessionCloseFence(sessionId), endSessionCloseFence: (sessionId) => this.endSessionCloseFence(sessionId), + assertSessionArchiveAllowed: (sessionId) => this.assertQueueIdleForHistory(sessionId), + runSessionMutation: (sessionId, operation) => { + if (this.queueCancellations.has(sessionId)) throw RequestError.invalidRequest("Session cancellation is in progress"); + return this.sessionOperation(sessionId, async () => { + if (this.queueCancellations.has(sessionId)) throw RequestError.invalidRequest("Session cancellation is in progress"); + return operation(); + }); + }, }); + private readonly queueMutations = new Set(); + private readonly queueRecoveryRequired = new Set(); constructor( connection: AcpClientConnection, @@ -511,8 +727,14 @@ export class CodexAcpServer { }, authMethods: getCodexAuthMethods(_params.clientCapabilities), _meta: { + runtime: runtimeCapability(), + archive: archiveCapability(), + fileRevert: fileRevertCapability(), + discovery: sessionDiscoveryCapability(), + ...(sessionQueueCapability(this.codexAcpClient.queueSupport) ? {queue: sessionQueueCapability(this.codexAcpClient.queueSupport)} : {}), steering: { supported: true, + idleBehavior: ["promptRequired"], }, // Only AIR gets the AIR extension, see `docs/air-extensions.md`. ...(this.capabilities.airClient ? { @@ -531,6 +753,7 @@ export class CodexAcpServer { AIR_RAW_INPUT_RENDERING_KEY, AIR_PLAN_CONTENT_DELTA_KEY, AIR_CODEX_HOOKS_KEY, + AIR_SESSION_REWIND_KEY, ...this.sessionIndex.agentCapabilities(), ], }, @@ -540,6 +763,362 @@ export class CodexAcpServer { }; } + private assertProviderAvailable(): void { + if (this.providerInvalidated) throw RequestError.invalidRequest("Native provider was invalidated after a timeout; reconnect the ACP adapter"); + } + + private async invalidateProviderAfterTimeout(): Promise { + // Poison admission BEFORE disposing the transport: late native completions must never + // install or publish a session, even if process termination itself fails. + this.providerInvalidated = true; + for (const session of this.sessions.values()) { + session.asyncTasks.clear(); + this.bumpSessionGeneration(session.sessionId); + this.mcpSessionStartup.close(session.sessionId); + } + for (const prompt of this.activePrompts.values()) prompt.requestClose(); + for (const owner of this.queueOwners.values()) owner.stop(); + this.queueOwners.clear(); + for (const pending of this.pendingTurnStarts.values()) pending.resolve(null); + this.sessions.clear(); + this.pendingTurnStarts.clear(); + this.steeringQueues.clear(); + if (this.codexProcessState !== null) await terminateCodexConnection(this.codexProcessState.connection); + } + + private async boundedRuntimeControl(operation: () => Promise): Promise { + const pending = operation(); + // settledWithin observes the losing promise as well; it never becomes an unhandled rejection. + const result = await settledWithin(pending.then(value => ({value})), 30_000); + if (result === "pending") { + await this.invalidateProviderAfterTimeout(); + throw RequestError.internalError(undefined, "Native runtime operation timed out; provider terminated, reconnect the ACP adapter"); + } + return result.value; + } + + private async boundedQueueOperation(operation: () => Promise): Promise { + const pending = operation(); + // settledWithin observes the losing promise as well; it never becomes an unhandled rejection. + const result = await settledWithin(pending.then(value => ({value})), 30_000); + if (result === "pending") { + await this.invalidateProviderAfterTimeout(); + throw RequestError.internalError(undefined, "Native queue operation timed out; provider terminated, reconnect the ACP adapter"); + } + return result.value; + } + + private assertNotRewinding(sessionId: string): void { + this.assertProviderAvailable(); + if (this.providerRuntimeControl || this.runtimeControls.has(sessionId)) throw RequestError.invalidRequest("Runtime control is in progress"); + if (this.queueMutations.has(sessionId)) throw RequestError.invalidRequest("Queue mutation is in progress"); + if (this.rewinds.has(sessionId)) throw RequestError.invalidRequest(`Session ${sessionId} is rewinding`); + } + + private assertQueueOperationAllowed(sessionId: string): void { + this.assertNotRewinding(sessionId); + } + + private assertQueueIdleForHistory(sessionId: string): void { + if (this.queueRecoveryRequired.has(sessionId)) throw RequestError.invalidRequest("Previous queue outcome is uncertain; load the session before continuing"); + if (this.queueOwners.get(sessionId)?.active() || this.queueHistoryLoads.has(sessionId) || this.queueCancellations.has(sessionId)) { + throw RequestError.invalidRequest("Queued work is active; cancel and settle it before changing history or provider runtime"); + } + } + + private async assertProviderQueuesIdle(): Promise { + for (const sessionId of this.sessions.keys()) { + this.assertQueueIdleForHistory(sessionId); + await this.assertNoPendingNativeQueue(sessionId); + } + } + + private async sessionOperation(sessionId: string, operation: () => Promise): Promise { + // Keep provider waits at their upstream request boundaries. Close and local + // settings must be able to settle a prompt/resume that a provider update awaits. + this.assertQueueOperationAllowed(sessionId); + this.sessionOperations.set(sessionId, (this.sessionOperations.get(sessionId) ?? 0) + 1); + try { return await operation(); } + finally { + const remaining = (this.sessionOperations.get(sessionId) ?? 1) - 1; + if (remaining === 0) this.sessionOperations.delete(sessionId); + else this.sessionOperations.set(sessionId, remaining); + } + } + + private async rewindSession(params: SessionRewindRequest): Promise<{rewound: boolean}> { + // A later provider restart waits for this operation; an earlier restart completes first. + while (this.providerUpdate !== null) await this.waitForProviderUpdate(); + this.assertNotRewinding(params.sessionId); + this.assertQueueIdleForHistory(params.sessionId); + const state = this.sessions.get(params.sessionId); + if (!state) throw RequestError.invalidParams(undefined, `Unknown session: ${params.sessionId}`); + if (this.rewindRecoveryRequired.has(params.sessionId)) { + throw RequestError.invalidRequest("Previous rewind outcome is uncertain; load the session before continuing"); + } + if (this.sessionIsClosing(params.sessionId) || this.sessionOperations.has(params.sessionId)) { + throw RequestError.invalidRequest("Session lifecycle or settings update is in progress"); + } + const prompt = this.activePrompts.get(params.sessionId); + // A turn/start that has not answered could write after the rewind. Do not synthesize + // completion or reuse closeSession's early-return path for this history mutation. + if (prompt && !prompt.currentTurn) throw RequestError.invalidRequest("Prompt startup is pending; cancel or retry after the turn starts"); + let release!: () => void; + this.rewinds.set(params.sessionId, new Promise(resolve => { release = resolve; })); + let mutationStarted = false; + try { + const sessionReady = this.ensureSessionReady(state); + if (sessionReady) await sessionReady; + await this.assertNoPendingNativeQueue(params.sessionId); + const result = await this.runWithProcessCheck(() => this.codexAcpClient.rewindSession(params, { + beforeMutation: async () => { + if (prompt) { + prompt.requestCancel(); + if (prompt.currentTurn) await this.requestTurnInterrupt(prompt.currentTurn, "Cancel"); + if (await settledWithin(prompt.completion, 10_000) === "pending") { + throw RequestError.invalidRequest("Prompt has not settled; rewind was not dispatched"); + } + } + this.assertQueueIdleForHistory(params.sessionId); + await this.assertNoPendingNativeQueue(params.sessionId); + const titleWrite = this.invalidateSessionTitles(params.sessionId, state.titleGen); + if (await settledWithin(titleWrite, 10_000) === "pending") { + throw RequestError.invalidRequest("Automatic title write is still pending; rewind was not dispatched"); + } + await this.codexAcpClient.waitForSessionNotifications(params.sessionId); + }, + mutationStarted: () => { mutationStarted = true; }, + })); + // An old asynchronous title/goal publication must not describe the discarded suffix. + this.bumpSessionGeneration(params.sessionId); + state.lastTokenUsage = null; + state.totalTokenUsage = null; + delete state.sessionFailure; + return result; + } catch (error) { + // The storage cutover can succeed before native reload/response delivery fails. + // Never repeat the mutation or send against an unverified continuation. + if (mutationStarted) this.rewindRecoveryRequired.add(params.sessionId); + throw error; + } finally { + this.rewinds.delete(params.sessionId); + release(); + } + } + + async revertFiles(request: SessionFileRevertRequest) { + while (this.providerUpdate !== null) await this.waitForProviderUpdate(); + this.assertNotRewinding(request.sessionId); + const state = this.getSessionState(request.sessionId); + if (this.providerCreationReservations || this.rewinds.size || this.sessionOperations.size || this.providerReads || this.activePrompts.size + || this.pendingTurnStarts.size || this.closingSessions.size) throw RequestError.invalidRequest("Provider is busy; settle work before reverting files"); + this.providerRuntimeControl = true; + try { + // Include native-loaded child threads, not just root ACP sessions. Fail closed if + // this native version cannot prove that its background terminals are idle. + await this.boundedRuntimeControl(async () => { + // Recovery can itself wait on queue/list or thread/resume. Bound that wait while + // holding the provider reservation, and reject late completion after invalidation. + const sessionReady = this.ensureSessionReady(state); + if (sessionReady) await sessionReady; + this.assertProviderAvailable(); + await this.assertProviderQueuesIdle(); + let cursor: string | null = null; + const seen = new Set(); + do { + const page = await this.codexAcpClient.appServerClient.threadLoadedList({cursor, limit: 100}); + for (const threadId of page.data) { + const thread = await this.codexAcpClient.appServerClient.threadRead({threadId}); + if (thread.thread.status.type === "active") throw RequestError.invalidRequest("Native thread is active; settle work before reverting files"); + const terminals = await this.codexAcpClient.appServerClient.threadBackgroundTerminalsList({threadId, limit: 1}); + if (terminals.data.length || terminals.nextCursor) throw RequestError.invalidRequest("Native background work is active"); + } + cursor = page.nextCursor; + if (cursor && seen.has(cursor)) throw RequestError.internalError(undefined, "Repeated loaded-thread cursor"); + if (cursor) seen.add(cursor); + } while (cursor); + }); + const result = await revertSessionFiles(request, state, () => this.boundedRuntimeControl(() => this.codexAcpClient.appServerClient.threadReadWithHistory(state.sessionId))); + if (result.reason === "restore_outcome_unknown") this.rewindRecoveryRequired.add(state.sessionId); + return result; + } finally { this.providerRuntimeControl = false; } + } + + private async assertNoPendingNativeQueue(sessionId: string): Promise { + if (!this.codexAcpClient.queueSupport.actions.includes("list")) return; + if (this.sessionIsClosing(sessionId)) throw RequestError.invalidRequest(`Session ${sessionId} is closing`); + const generation = this.getSessionGeneration(sessionId); + const client = this.codexAcpClient; + let page; + try { page = await client.appServerClient.queueNative().list({threadId: sessionId, limit: 1}); } + catch (error) { + // Preserve the existing public invalid-session diagnostic; this is a read validation + // error, never a reason to resume, mutate history or skip pending-queue checks. + if (error instanceof Error && error.message.startsWith("invalid thread id:")) { + throw RequestError.invalidParams(undefined, "invalid session id"); + } + throw error; + } + if (this.codexAcpClient !== client || this.getSessionGeneration(sessionId) !== generation || this.sessionIsClosing(sessionId)) { + throw RequestError.invalidRequest(`Session ${sessionId} changed while checking its native queue`); + } + if (page.data.length || page.nextCursor) throw RequestError.invalidRequest("Pending native queue must be started or cleared before this operation"); + } + + async manageSessionQueue(value: unknown, signal?: AbortSignal): Promise { + const request = parseSessionQueueRequest(value); + const cancelGeneration = this.queueCancelGenerations.get(request.sessionId) ?? 0; + while (this.providerUpdate !== null) await this.waitForProviderUpdate(); + this.assertQueueOperationAllowed(request.sessionId); + if (this.rewindRecoveryRequired.has(request.sessionId)) throw RequestError.invalidRequest("Previous rewind outcome is uncertain; load the session before continuing"); + if (this.queueRecoveryRequired.has(request.sessionId)) throw RequestError.invalidRequest("Load the session to reconcile its native state first"); + const state = this.sessions.get(request.sessionId); + const prompt = this.activePrompts.get(request.sessionId); + if (request.action === "add" && prompt && prompt.currentTurn === null) throw RequestError.invalidRequest("Prompt startup is pending"); + if (this.sessionIsClosing(request.sessionId) || this.sessionOperations.has(request.sessionId)) throw RequestError.invalidRequest("Session is busy"); + return this.sessionOperation(request.sessionId, async () => { + this.queueMutations.add(request.sessionId); + let dispatched = false; + try { + const appServer = this.ensureAppServer(); + if (appServer) await appServer; + const capability = sessionQueueCapability(this.codexAcpClient.queueSupport); + if (!capability?.actions.includes(request.action)) return {status: "unsupported"}; + // A restart may auto-dispatch persisted work when the thread resumes. Only an + // explicit load may reconcile it; never resume or replay a queue write implicitly. + if (state && this.recovery !== null && !this.recovery.sessionIsLive(state)) { + throw RequestError.invalidRequest("Load the session after the app-server restart before managing its queue"); + } + if (signal?.aborted) throw RequestError.invalidRequest("Queue request cancelled before dispatch"); + if (request.action === "add" && !state) { + const native = await this.codexAcpClient.appServerClient.threadRead({threadId: request.sessionId}); + if (native.thread.status.type !== "notLoaded") throw RequestError.invalidRequest("Load the native session before adding to a live queue"); + } + if (state && !this.queueOwners.has(request.sessionId)) await this.ownNativeQueue(request.sessionId, state); + if (request.action === "start" && (!state || this.activePrompts.has(request.sessionId) || this.queueOwners.get(request.sessionId)?.active())) { + throw RequestError.invalidRequest("Load an idle session before starting its queue"); + } + if (signal?.aborted) throw RequestError.invalidRequest("Queue request cancelled before dispatch"); + if ((request.action === "add" || request.action === "start") + && (this.queueCancelGenerations.get(request.sessionId) ?? 0) === cancelGeneration) this.queueOwners.get(request.sessionId)?.allow(); + dispatched = true; + return await this.boundedQueueOperation(() => runSessionQueue(request, {sessionId: request.sessionId, support: this.codexAcpClient.queueSupport, native: this.codexAcpClient.appServerClient.queueNative()})); + } catch (error) { + // Transport/internal failures may follow a durable commit. Never retry writes. + if (dispatched && request.action !== "list" && !(typeof error === "object" && error !== null && "code" in error && + (error.code === -32600 || error.code === -32601 || error.code === -32602))) { + this.queueRecoveryRequired.add(request.sessionId); + } + throw error; + } + finally { this.queueMutations.delete(request.sessionId); } + }); + } + + async searchSessionHistory(params: SessionSearchRequest) { + this.assertProviderAvailable(); + while (this.providerUpdate !== null) await this.waitForProviderUpdate(); + if (this.providerRuntimeControl) throw RequestError.invalidRequest("Runtime control is in progress"); + this.providerReads++; + try { + const appServer = this.ensureAppServer(); + if (appServer) await appServer; + return await searchSessions(this.codexAcpClient.appServerClient, params); + } + finally { this.providerReads--; } + } + + async manageSessionAttachments(params: SessionAttachmentsRequest) { + while (this.providerUpdate !== null) await this.waitForProviderUpdate(); + return this.sessionOperation(params.sessionId, async () => { + const state = this.getSessionState(params.sessionId); + if (this.sessionIsClosing(params.sessionId)) throw RequestError.invalidRequest("Session is closing"); + this.runtimeControls.add(params.sessionId); + try { + const sessionReady = this.ensureSessionReady(state); + if (sessionReady) await sessionReady; + return await sessionAttachments(this.codexAcpClient.appServerClient, state.sessionId, params); + } + finally { this.runtimeControls.delete(params.sessionId); } + }); + } + + async readSessionRuntime(request: RuntimeReadRequest, signal: AbortSignal = new AbortController().signal): Promise { + request = runtimeReadParser.parse(request); + while (this.providerUpdate !== null) await this.waitForProviderUpdate(); + return this.sessionOperation(request.sessionId, async () => { + const state = this.getSessionState(request.sessionId); + const sessionReady = this.ensureSessionReady(state); + if (sessionReady) await sessionReady; + const client = this.codexAcpClient; + const generation = this.getSessionGeneration(request.sessionId); + return readRuntime(client, state, request, signal, () => + this.codexAcpClient === client && this.sessionPublishIsCurrent(state, generation)); + }); + } + + async controlSessionRuntime(request: RuntimeControlRequest): Promise { + request = runtimeControlParser.parse(request); + while (this.providerUpdate !== null) await this.waitForProviderUpdate(); + this.assertNotRewinding(request.sessionId); + const state = this.getSessionState(request.sessionId); + if (request.action === "reconnectMcp" || request.action === "reloadPlugins") { + if (this.providerCreationReservations || this.rewinds.size || this.sessionOperations.size || this.providerReads || this.activePrompts.size + || this.pendingTurnStarts.size || this.closingSessions.size) { + throw RequestError.invalidRequest("Provider is busy; settle its sessions before reconnecting MCP"); + } + this.providerRuntimeControl = true; + try { + return await this.boundedRuntimeControl(async () => { + const sessionReady = this.ensureSessionReady(state); + if (sessionReady) await sessionReady; + await this.assertProviderQueuesIdle(); + return this.runWithProcessCheck(() => controlRuntime(this.codexAcpClient, state, request)); + }); + } + finally { this.providerRuntimeControl = false; } + } + if (this.sessionOperations.has(request.sessionId) || this.sessionIsClosing(request.sessionId)) { + throw RequestError.invalidRequest("Session lifecycle or settings update is in progress"); + } + return this.sessionOperation(request.sessionId, async () => { + this.runtimeControls.add(request.sessionId); + try { + return await this.boundedRuntimeControl(async () => { + const sessionReady = this.ensureSessionReady(state); + if (sessionReady) await sessionReady; + const result = await this.runWithProcessCheck(() => controlRuntime(this.codexAcpClient, state, request)); + this.assertProviderAvailable(); + await this.publishAvailableCommands(state, this.getSessionGeneration(request.sessionId)); + return result; + }); + } finally { this.runtimeControls.delete(request.sessionId); } + }); + } + + async archiveSession(sessionId: string, archived: boolean): Promise<{sessionId: string; archived: boolean}> { + while (this.providerUpdate !== null) await this.waitForProviderUpdate(); + this.assertNotRewinding(sessionId); + this.assertQueueIdleForHistory(sessionId); + if (this.sessionOperations.has(sessionId) || this.activePrompts.has(sessionId) || this.pendingTurnStarts.has(sessionId) + || this.sessionIsClosing(sessionId)) throw RequestError.invalidRequest("Session is busy; settle work before archiving"); + // Hold a named fence until native archive/unarchive completes. closeSession is cleanup, + // not a writer-lock handoff: the same app-server performs the archive. + return this.sessionOperation(sessionId, async () => { + this.beginSessionCloseFence(sessionId); + try { + const appServer = this.ensureAppServer(); + if (appServer) await appServer; + if (this.hasLocalSession(sessionId) && this.sessions.has(sessionId)) await this.closeSessionDuringOperation({sessionId}); + await this.runWithProcessCheck(() => archived + ? this.codexAcpClient.archiveSession(sessionId) + : this.codexAcpClient.unarchiveSession(sessionId)); + this.rewindRecoveryRequired.delete(sessionId); + return {sessionId, archived}; + } finally { this.endSessionCloseFence(sessionId); } + }); + } + async listHooks(cwd: string): Promise { if (this.providerUpdate !== null) await this.waitForProviderUpdate(); const appServer = this.ensureAppServer(); @@ -555,6 +1134,13 @@ export class CodexAcpServer { } async extMethod(method: string, params: Record): Promise> { + if ((method === GOAL_CONTROL_METHOD || method === LEGACY_GOAL_CONTROL_METHOD) && typeof params["sessionId"] === "string") { + return this.sessionOperation(params["sessionId"], () => this.extMethodDuringOperation(method, params)); + } + return this.extMethodDuringOperation(method, params); + } + + private async extMethodDuringOperation(method: string, params: Record): Promise> { const methodRequest = { method: method, params: params }; if (!isExtMethodRequest(methodRequest)) { return {}; @@ -584,6 +1170,8 @@ export class CodexAcpServer { ), }; } + case SESSION_REWIND_METHOD: + return await this.rewindSession(methodRequest.params); case GOAL_CONTROL_METHOD: case LEGACY_GOAL_CONTROL_METHOD: { const sessionState = this.sessions.get(methodRequest.params.sessionId); @@ -776,11 +1364,15 @@ export class CodexAcpServer { } this.beginSessionCloseFence(sessionId); try { + this.queueOwners.get(sessionId)?.cancel(); // A dead app-server holds nothing to close. if (this.recovery === null || this.recovery.isReady()) { + const turnId = this.queueOwners.get(sessionId)?.nativeTurnId(); + if (turnId) await this.requestTurnInterrupt({threadId: sessionId, turnId}, "Close"); await this.runWithProcessCheck(() => this.codexAcpClient.closeSession(sessionId)); } } catch (err) { + if (this.queueOwners.get(sessionId)?.nativeTurnId()) await this.invalidateProviderAfterTimeout(); logger.error(`Failed to close stale session open for ${sessionId}`, err); } finally { this.endSessionCloseFence(sessionId); @@ -1130,8 +1722,37 @@ export class CodexAcpServer { } private installSessionState(sessionState: SessionState): void { - this.sessions.get(sessionState.sessionId)?.asyncTasks.clear(); + this.assertProviderAvailable(); + const previous = this.sessions.get(sessionState.sessionId); + previous?.asyncTasks.clear(); + this.retainRetiredTitleGenerator(sessionState.sessionId, previous?.titleGen); this.sessions.set(sessionState.sessionId, sessionState); + if (this.codexAcpClient.queueSupport.actions.includes("add") && !this.queueHistoryLoads.has(sessionState.sessionId)) void this.ownNativeQueue(sessionState.sessionId, sessionState); + } + + private retainRetiredTitleGenerator(sessionId: string, title: TitleGenerator | undefined): void { + if (!title) return; + let retired = this.retiredTitleGenerators.get(sessionId); + if (!retired) this.retiredTitleGenerators.set(sessionId, retired = new Set()); + retired.add(title); + void title.retire().then(() => this.forgetRetiredTitleGenerator(sessionId, title), () => { + // Keep an unacknowledged native write fenced, including across close/reopen. + }); + } + + private forgetRetiredTitleGenerator(sessionId: string, title: TitleGenerator): void { + const retired = this.retiredTitleGenerators.get(sessionId); + retired?.delete(title); + if (retired?.size === 0) this.retiredTitleGenerators.delete(sessionId); + } + + private async invalidateSessionTitles(sessionId: string, current: TitleGenerator | undefined): Promise { + const titles = new Set(this.retiredTitleGenerators.get(sessionId)); + if (current) titles.add(current); + await Promise.all([...titles].map(async title => { + await title.invalidateForRewind(); + this.forgetRetiredTitleGenerator(sessionId, title); + })); } private getAuthProviderForAuthenticateRequest(request: acp.AuthenticateRequest): string | null { @@ -1148,11 +1769,40 @@ export class CodexAcpServer { } private async loadSessionOnce(params: acp.LoadSessionRequest): Promise { + return this.sessionOperation(params.sessionId, async () => { + if (this.queueOwners.get(params.sessionId)?.active() || this.activePrompts.has(params.sessionId)) throw RequestError.invalidRequest("Session has an active turn"); + if (this.sessionIsClosing(params.sessionId)) throw RequestError.invalidRequest(`Session ${params.sessionId} is closing`); + this.queueOwners.get(params.sessionId)?.stop(); + this.queueOwners.delete(params.sessionId); + this.queueHistoryLoads.add(params.sessionId); + try { + const result = await this.loadSessionDuringOperation(params); + this.queueRecoveryRequired.delete(params.sessionId); + this.rewindRecoveryRequired.delete(params.sessionId); + return result; + } catch (error) { + if (this.queueOwners.get(params.sessionId)?.nativeTurnId()) { + await this.closeSessionDuringOperation({sessionId: params.sessionId}).catch(closeError => { + logger.error("Failed to stop native work after load failure", closeError); + }); + } + this.queueOwners.get(params.sessionId)?.stop(); + this.queueOwners.delete(params.sessionId); + throw error; + } finally { + this.queueHistoryLoads.delete(params.sessionId); + } + }); + } + + private async loadSessionDuringOperation(params: acp.LoadSessionRequest): Promise { if (this.providerUpdate !== null) { await this.waitForProviderUpdate(); } const appServer = this.ensureAppServer(); if (appServer) await appServer; + if (this.codexAcpClient.queueSupport.actions.includes("add")) await this.ownNativeQueue(params.sessionId); + else if (this.codexAcpClient.queueSupport.actions.includes("list")) await this.assertNoPendingNativeQueue(params.sessionId); logger.log("Loading session...", {sessionId: params.sessionId}); // Captured before the load installs a fresh SessionState: a title // generation started by an earlier turn on this session belongs to the @@ -1184,6 +1834,7 @@ export class CodexAcpServer { // A load response means "the replay is complete"; a late rename echo // from a still-running title generation would arrive after it. await previousTitleGen?.waitForIdle(TITLE_GENERATION_SETTLE_TIMEOUT_MS); + this.queueOwners.get(sessionId)?.install(this.getSessionState(sessionId)); logger.log("Session loaded", { sessionId: sessionId, @@ -1204,11 +1855,34 @@ export class CodexAcpServer { } private async resumeSessionOnce(params: acp.ResumeSessionRequest): Promise { + return this.sessionOperation(params.sessionId, async () => { + if (this.queueOwners.get(params.sessionId)?.active() || this.activePrompts.has(params.sessionId)) throw RequestError.invalidRequest("Session has an active turn"); + if (this.sessionIsClosing(params.sessionId)) throw RequestError.invalidRequest(`Session ${params.sessionId} is closing`); + this.queueOwners.get(params.sessionId)?.stop(); + this.queueOwners.delete(params.sessionId); + try { + return await this.resumeSessionDuringOperation(params); + } catch (error) { + if (this.queueOwners.get(params.sessionId)?.nativeTurnId()) { + await this.closeSessionDuringOperation({sessionId: params.sessionId}).catch(closeError => { + logger.error("Failed to stop native work after resume failure", closeError); + }); + } + this.queueOwners.get(params.sessionId)?.stop(); + this.queueOwners.delete(params.sessionId); + throw error; + } + }); + } + + private async resumeSessionDuringOperation(params: acp.ResumeSessionRequest): Promise { if (this.providerUpdate !== null) { await this.waitForProviderUpdate(); } const appServer = this.ensureAppServer(); if (appServer) await appServer; + if (this.codexAcpClient.queueSupport.actions.includes("add")) await this.ownNativeQueue(params.sessionId); + else if (this.codexAcpClient.queueSupport.actions.includes("list")) await this.assertNoPendingNativeQueue(params.sessionId); logger.log("Resuming session...", {sessionId: params.sessionId}); const [sessionId, modelState, modeState] = await this.getOrCreateSession(params); @@ -1225,6 +1899,13 @@ export class CodexAcpServer { } async forkSession(params: acp.ForkSessionRequest): Promise { + return this.sessionOperation(params.sessionId, async () => { + const result = await this.forkSessionDuringOperation(params); + return result; + }); + } + + private async forkSessionDuringOperation(params: acp.ForkSessionRequest): Promise { if (this.providerUpdate !== null) { await this.waitForProviderUpdate(); } @@ -1271,12 +1952,25 @@ export class CodexAcpServer { } async closeSession(params: acp.CloseSessionRequest): Promise { + if (this.providerInvalidated) return {}; + return this.sessionOperation(params.sessionId, async () => { + const result = await this.closeSessionDuringOperation(params); + return result; + }); + } + + private async closeSessionDuringOperation(params: acp.CloseSessionRequest): Promise { logger.log("Closing session...", {sessionId: params.sessionId}); const closeGeneration = this.bumpSessionGeneration(params.sessionId); const sessionState = this.sessions.get(params.sessionId); this.beginSessionCloseFence(params.sessionId); + this.queueOwners.get(params.sessionId)?.cancel(); try { + const queuedTurn = this.queueOwners.get(params.sessionId)?.nativeTurnId(); + if (queuedTurn && sessionState?.currentTurnId !== queuedTurn) { + await this.requestTurnInterrupt({threadId: params.sessionId, turnId: queuedTurn}, "Close"); + } if (sessionState) { await this.interruptSessionTurn(sessionState, "Close", true); sessionState.asyncTasks.clear(); @@ -1297,9 +1991,18 @@ export class CodexAcpServer { logger.log("Session closed without an app-server call: its app-server is gone", {sessionId: params.sessionId}); } logger.log("Session closed", {sessionId: params.sessionId}); + } catch (error) { + if (this.queueOwners.get(params.sessionId)?.nativeTurnId()) { + // Do not unsubscribe and forget a writer whose interruption failed. + await this.invalidateProviderAfterTimeout(); + } + throw error; } finally { if (this.getSessionGeneration(params.sessionId) === closeGeneration) { + this.retainRetiredTitleGenerator(params.sessionId, this.sessions.get(params.sessionId)?.titleGen); this.sessions.delete(params.sessionId); + this.queueOwners.get(params.sessionId)?.stop(); + this.queueOwners.delete(params.sessionId); this.mcpSessionStartup.close(params.sessionId); this.codexAcpClient.appServerClient.mcpStartup.forgetThread(params.sessionId); this.pendingTurnStarts.delete(params.sessionId); @@ -1313,7 +2016,22 @@ export class CodexAcpServer { return {}; } + async renameSession(params: {sessionId: string; title: string}): Promise> { + return this.sessionOperation(params.sessionId, () => this.sessionIndex.rename(params)); + } + + async setSessionArchived(params: {sessionId: string}, archived: boolean): Promise> { + return this.sessionOperation(params.sessionId, () => this.sessionIndex.setArchived(params, archived)); + } + async deleteSession(params: acp.DeleteSessionRequest): Promise { + return this.sessionOperation(params.sessionId, async () => { + const result = await this.deleteSessionDuringOperation(params); + return result; + }); + } + + private async deleteSessionDuringOperation(params: acp.DeleteSessionRequest): Promise { const appServer = this.ensureAppServer(); if (appServer) await appServer; logger.log("Deleting session...", {sessionId: params.sessionId}); @@ -1357,19 +2075,23 @@ export class CodexAcpServer { async newSession( params: acp.NewSessionRequest, ): Promise { - if (this.providerUpdate !== null) { - await this.waitForProviderUpdate(); - } + this.assertProviderAvailable(); + if (this.providerRuntimeControl) throw RequestError.invalidRequest("Runtime control is in progress"); + this.providerCreationReservations++; + try { + while (this.providerUpdate !== null) await this.waitForProviderUpdate(); + this.assertProviderAvailable(); + if (this.providerRuntimeControl) throw RequestError.invalidRequest("Runtime control is in progress"); const appServer = this.ensureAppServer(); if (appServer) await appServer; logger.log("Starting new session..."); const [sessionId, modelState, modeState] = await this.getOrCreateSession(params); - logger.log("New session created", { - sessionId: sessionId, - modelId: modelState.currentModelId, - availableModelCount: modelState.availableModels.length - }); + logger.log("New session created", { + sessionId: sessionId, + modelId: modelState.currentModelId, + availableModelCount: modelState.availableModels.length + }); return { sessionId: sessionId, @@ -1377,12 +2099,15 @@ export class CodexAcpServer { modes: modeState, ...this.createSessionConfigOptionsResponse(this.getSessionState(sessionId)), }; + } finally { this.providerCreationReservations--; } } async authenticate( _params: acp.AuthenticateRequest, requestId?: acp.JsonRpcId, ): Promise { + this.assertProviderAvailable(); + if (this.providerCreationReservations || this.providerRuntimeControl || this.rewinds.size > 0) throw RequestError.invalidRequest("Provider history or runtime operation is in progress"); logger.log("Authenticate request received"); const appServer = this.ensureAppServer(); if (appServer) await appServer; @@ -1423,6 +2148,8 @@ export class CodexAcpServer { } async logout(_params: acp.LogoutRequest): Promise { + this.assertProviderAvailable(); + if (this.providerCreationReservations || this.providerRuntimeControl || this.rewinds.size > 0) throw RequestError.invalidRequest("Provider history or runtime operation is in progress"); logger.log("Logout request received"); const appServer = this.ensureAppServer(); if (appServer) await appServer; @@ -1436,14 +2163,23 @@ export class CodexAcpServer { } async setProvider(params: acp.SetProviderRequest): Promise { - this.codexAcpClient.setProvider(params); + this.assertProviderAvailable(); + if (this.providerRuntimeControl || this.providerCreationReservations) throw RequestError.invalidRequest("Runtime control or session creation is in progress"); + while (this.rewinds.size > 0) await Promise.all([...this.rewinds.values()]); + if (this.providerRuntimeControl) throw RequestError.invalidRequest("Runtime control is in progress"); + if (this.providerCreationReservations || this.sessionOperations.size > 0 || this.providerReads > 0) throw RequestError.invalidRequest("A session lifecycle or settings operation is in progress"); await this.enqueueProviderUpdate((client) => client.setProvider(params)); return { }; } async disableProvider(params: acp.DisableProviderRequest): Promise { - this.codexAcpClient.disableProvider(params); + this.assertProviderAvailable(); + if (this.providerRuntimeControl || this.providerCreationReservations) throw RequestError.invalidRequest("Runtime control or session creation is in progress"); + while (this.rewinds.size > 0) await Promise.all([...this.rewinds.values()]); + if (this.providerRuntimeControl) throw RequestError.invalidRequest("Runtime control is in progress"); + if (this.providerCreationReservations || this.sessionOperations.size > 0 || this.providerReads > 0) throw RequestError.invalidRequest("A session lifecycle or settings operation is in progress"); if (params.providerId !== OPENAI_PROVIDER_ID) { + this.codexAcpClient.disableProvider(params); return { }; } await this.enqueueProviderUpdate((client) => client.disableProvider(params)); @@ -1451,9 +2187,15 @@ export class CodexAcpServer { } private async enqueueProviderUpdate(apply: (client: CodexAcpClient) => void): Promise { + if ([...this.queueOwners.values()].some(owner => owner.active())) throw RequestError.invalidRequest("Cancel queued turns before replacing the provider"); + if (this.sessions.size === 0 && this.providerUpdate === null) { + apply(this.codexAcpClient); + return; + } const previous = this.providerUpdate?.catch(() => undefined) ?? Promise.resolve(); const update = previous.then(async () => { if (this.sessions.size === 0) { + apply(this.codexAcpClient); return; } @@ -1462,18 +2204,24 @@ export class CodexAcpServer { logger.log("Waiting for active prompts before provider restart", {count: activePrompts.length}); await Promise.all(activePrompts); } + for (const session of this.sessions.values()) await this.assertNoPendingNativeQueue(session.sessionId); + if ([...this.queueOwners.values()].some(owner => owner.active())) throw RequestError.invalidRequest("Cancel queued turns before replacing the provider"); logger.log("Restarting Codex app-server for provider update", {sessionCount: this.sessions.size}); for (const session of this.sessions.values()) { session.asyncTasks.prepareForAppServerReplacement(); } await this.finishAllAsyncTasks("stopped", "before the provider restart"); + if ([...this.queueOwners.values()].some(owner => owner.active())) throw RequestError.invalidRequest("A queued turn started before provider replacement"); + apply(this.codexAcpClient); const replacement = await this.restartCodexClient(); apply(replacement); if (this.initializeRequest === null) { throw new Error("Cannot restart Codex app-server before ACP initialization"); } await replacement.initialize(this.initializeRequest); + for (const owner of this.queueOwners.values()) owner.stop(); + this.queueOwners.clear(); this.codexAcpClient = replacement; this.sessionIndex.observe(replacement); this.availableCommands = this.createAvailableCommands(replacement); @@ -1489,6 +2237,12 @@ export class CodexAcpServer { break; } try { + if (replacement.queueSupport.actions.includes("add")) { + await this.ownNativeQueue(session.sessionId, session); + } else if (replacement.queueSupport.actions.includes("list")) { + const pending = await replacement.appServerClient.queueNative().list({threadId: session.sessionId, limit: 1}); + if (pending.data.length || pending.nextCursor) throw RequestError.invalidRequest("Native pending queue cannot be auto-resumed by ACP"); + } const request = { sessionId: session.sessionId, cwd: session.cwd, @@ -1578,6 +2332,7 @@ export class CodexAcpServer { * `const appServer = this.ensureAppServer(); if (appServer) await appServer;`. */ private ensureAppServer(): Promise | undefined { + this.assertProviderAvailable(); return this.recovery?.ensureRunning(); } @@ -1613,6 +2368,8 @@ export class CodexAcpServer { : null, install: (client) => this.installCodexClient(client), crashed: () => { + for (const owner of this.queueOwners.values()) owner.stop(); + this.queueOwners.clear(); // Every session takes its tasks now, before any await, so a task that a restarted app-server starts // meanwhile is not failed with the old ones. for (const session of this.sessions.values()) { @@ -1622,6 +2379,14 @@ export class CodexAcpServer { } }, resumeSession: async (session, client, onSubscribed) => { + // Explicit load owns auto-dispatch and failed-load cleanup. A lazy recovery + // of an ordinary request must not start persisted work as a side effect. + if (client.queueSupport.actions.includes("list")) { + await this.assertNoPendingNativeQueue(session.sessionId); + } + if (client.queueSupport.actions.includes("add")) { + await this.ownNativeQueue(session.sessionId, session); + } const metadata = await client.resumeSession({ sessionId: session.sessionId, cwd: session.cwd, @@ -1644,6 +2409,9 @@ export class CodexAcpServer { /** Makes `client`, of a restarted app-server, the client of the agent and of every session. */ private installCodexClient(client: CodexAcpClient): void { + this.assertProviderAvailable(); + for (const owner of this.queueOwners.values()) owner.stop(); + this.queueOwners.clear(); // A `providers/set` can have changed the routing of the old client while the restart ran. client.adoptRoutingFrom(this.codexAcpClient); this.codexAcpClient = client; @@ -1844,6 +2612,8 @@ export class CodexAcpServer { async setSessionMode( _params: acp.SetSessionModeRequest, ): Promise { + this.assertNotRewinding(_params.sessionId); + this.assertQueueOperationAllowed(_params.sessionId); logger.log("Set session mode requested", { sessionId: _params.sessionId, modeId: _params.modeId @@ -1856,6 +2626,13 @@ export class CodexAcpServer { } async setSessionConfigOption(params: acp.SetSessionConfigOptionRequest): Promise { + return this.sessionOperation(params.sessionId, async () => { + const result = await this.setSessionConfigOptionDuringOperation(params); + return result; + }); + } + + private async setSessionConfigOptionDuringOperation(params: acp.SetSessionConfigOptionRequest): Promise { logger.log("Set session config option requested", { sessionId: params.sessionId, configId: params.configId, @@ -1965,6 +2742,13 @@ export class CodexAcpServer { } async unstable_setSessionModel(params: LegacySetSessionModelRequest): Promise { + return this.sessionOperation(params.sessionId, async () => { + const result = await this.unstable_setSessionModelDuringOperation(params); + return result; + }); + } + + private async unstable_setSessionModelDuringOperation(params: LegacySetSessionModelRequest): Promise { logger.log("Set session model requested", { sessionId: params.sessionId, modelId: params.modelId @@ -2027,6 +2811,15 @@ export class CodexAcpServer { * {@link performSteeringRequest}. */ async executeOrQueueSteeringRequest(params: SessionSteerRequest): Promise { + return this.sessionOperation(params.sessionId, async () => { + const result = await this.executeOrQueueSteeringRequestDuringOperation(params); + return result; + }); + } + + private async executeOrQueueSteeringRequestDuringOperation(params: SessionSteerRequest): Promise { + if (this.rewindRecoveryRequired.has(params.sessionId)) throw RequestError.invalidRequest("Previous rewind outcome is uncertain; load the session before continuing"); + if (this.queueRecoveryRequired.has(params.sessionId)) throw RequestError.invalidRequest("Previous queue outcome is uncertain; load the session before continuing"); const queue = this.getSteeringQueue(params.sessionId); try { return await queue.enqueue(params); @@ -2083,6 +2876,7 @@ export class CodexAcpServer { return {outcome: "injected"}; } } + if (params._meta?.steering?.idleBehavior === "promptRequired") return {outcome: "promptRequired"}; return await this.startNewTurnFromSteering(params); } @@ -2263,9 +3057,14 @@ export class CodexAcpServer { if (typeof sessionId !== "string" || !Array.isArray(prompt)) { throw RequestError.invalidParams(); } + const meta = params["_meta"]; + const steering = typeof meta === "object" && meta !== null && "steering" in meta ? meta.steering : undefined; + const idleBehavior = typeof steering === "object" && steering !== null && "idleBehavior" in steering ? steering.idleBehavior : undefined; + if (idleBehavior !== undefined && idleBehavior !== "promptRequired") throw RequestError.invalidParams(undefined, "Unknown steering idle behavior"); return { - sessionId: sessionId, + sessionId, prompt: prompt as acp.ContentBlock[], + ...(idleBehavior === "promptRequired" ? {_meta: {steering: {idleBehavior}}} : {}), }; } @@ -2366,7 +3165,7 @@ export class CodexAcpServer { } private sessionPublishIsCurrent(sessionState: SessionState, sessionGeneration: number): boolean { - return this.sessions.get(sessionState.sessionId) === sessionState + return !this.providerInvalidated && this.sessions.get(sessionState.sessionId) === sessionState && this.getSessionGeneration(sessionState.sessionId) === sessionGeneration && !this.sessionIsClosing(sessionState.sessionId); } @@ -2866,7 +3665,7 @@ export class CodexAcpServer { private createUserMessageUpdates(item: ThreadItem & { type: "userMessage" }): UpdateSessionEvent[] { const updates: UpdateSessionEvent[] = []; const messageId = item.id; - const contentBlocks = item.content.map(input => this.userInputToContentBlocks(input)); + const contentBlocks = item.content.map(input => userInputToContentBlocks(input)); const attachmentUris = new Set(contentBlocks.flatMap((blocks, index) => item.content[index]?.type === "text" ? blocks.flatMap(block => block.type === "resource_link" ? [block.uri] : []) @@ -2884,6 +3683,19 @@ export class CodexAcpServer { return updates; } + + private formatUriAsLink(name: string | null, uri: string): string { + if (name && name.length > 0) { + return `[@${name}](${uri})`; + } + if (uri.startsWith("file://")) { + const path = uri.replace("file://", ""); + const fileName = path.split("/").pop() ?? path; + return `[@${fileName}](${uri})`; + } + return uri; + } + private createReasoningUpdates(item: ThreadItem & { type: "reasoning" }): UpdateSessionEvent[] { const parts = item.summary.length > 0 ? item.summary : item.content; const messageId = item.id; @@ -2923,47 +3735,6 @@ export class CodexAcpServer { ); } - private userInputToContentBlocks(input: UserInput): acp.ContentBlock[] { - switch (input.type) { - case "text": - return desktopAttachmentHistory(input.text) - ?? (input.text.length > 0 ? [{ type: "text", text: input.text }] : []); - case "image": - return [{ - type: "text", - text: "url" in input - ? this.formatUriAsLink("image", input.url) - : `image:${input.fileId}`, - }]; - case "localImage": - case "localAudio": - case "mention": { - const uri = attachmentFileUri(input.path); - const fileName = input.path.split(/[\\/]/).pop() || input.type; - const name = input.type === "mention" && input.name.trim().length > 0 ? input.name : fileName; - return uri !== null - ? [{type: "resource_link", name, uri}] - : [{type: "text", text: this.formatUriAsLink(name, input.path)}]; - } - case "skill": - return [{ type: "text", text: `skill:${input.name} (${input.path})` }]; - case "audio": - return [{type: "text", text: this.formatUriAsLink("audio", input.url)}]; - } - } - - private formatUriAsLink(name: string | null, uri: string): string { - if (name && name.length > 0) { - return `[@${name}](${uri})`; - } - if (uri.startsWith("file://")) { - const path = uri.replace("file://", ""); - const fileName = path.split("/").pop() ?? path; - return `[@${fileName}](${uri})`; - } - return uri; - } - getSessionState(sessionId: string): SessionState { const sessionState = this.sessions.get(sessionId); if (!sessionState) { @@ -3151,14 +3922,19 @@ export class CodexAcpServer { }); return; } catch (err) { - const retryDelay = requestName === "Cancel" + const retryDelay = (requestName === "Cancel" || this.queueOwners.get(turn.threadId)?.nativeTurnId() === turn.turnId) && isNoActiveTurnError(err) && attempt < NO_ACTIVE_TURN_RETRY_DELAYS_MS.length - && this.activePrompts.has(turn.threadId) + && (this.activePrompts.has(turn.threadId) + || this.queueOwners.get(turn.threadId)?.nativeTurnId() === turn.turnId) ? NO_ACTIVE_TURN_RETRY_DELAYS_MS[attempt]! : null; if (retryDelay === null) { logger.error(`${requestName} - turnInterrupt failed`, err); + if (this.queueOwners.get(turn.threadId)?.nativeTurnId() === turn.turnId) { + this.queueRecoveryRequired.add(turn.threadId); + throw RequestError.internalError(undefined, "Queued turn interruption unconfirmed; reload required"); + } return; } // The cancel raced the turn's registration in Codex: the prompt @@ -3178,6 +3954,8 @@ export class CodexAcpServer { private turnAppServerIsLive(threadId: string): boolean { if (this.recovery === null) return true; + // A cold resume can start queued work before its new SessionState is installed. + if (this.queueOwners.get(threadId)?.nativeTurnId()) return this.recovery.isReady(); const sessionState = this.sessions.get(threadId); return sessionState === undefined ? this.recovery.isReady() : this.recovery.sessionIsLive(sessionState); } @@ -3256,6 +4034,18 @@ export class CodexAcpServer { if (this.providerUpdate !== null) { await this.waitForProviderUpdate(); } + this.assertNotRewinding(params.sessionId); + if (this.rewindRecoveryRequired.has(params.sessionId)) { + throw RequestError.invalidRequest("Previous rewind outcome is uncertain; load the session before continuing"); + } + this.assertQueueOperationAllowed(params.sessionId); + if (this.queueRecoveryRequired.has(params.sessionId)) { + throw RequestError.invalidRequest("Previous queue outcome is uncertain; load the session before continuing"); + } + if (this.sessionIsClosing(params.sessionId)) throw RequestError.invalidRequest("Session is closing"); + if (this.activePrompts.has(params.sessionId)) throw RequestError.invalidRequest("Prompt is already running"); + if (this.queueHistoryLoads.has(params.sessionId)) throw RequestError.invalidRequest("Session history is loading"); + if (this.queueOwners.get(params.sessionId)?.active()) throw RequestError.invalidRequest("A queued turn is running"); logger.log("Prompt received", { sessionId: params.sessionId, prompt: params.prompt, @@ -3275,6 +4065,7 @@ export class CodexAcpServer { // A prompt that starts no model work, such as /status or a cancellation before the turn, used no tokens. sessionState.promptTokenUsage = null; const activePrompt = this.trackActivePrompt(params.sessionId); + let completedPromptUsage: TokenCount | null | undefined; let pendingTurnStart: PendingTurnStart | null = null; const ensurePendingTurnStart = (): PendingTurnStart => { if (pendingTurnStart === null) { @@ -3300,7 +4091,7 @@ export class CodexAcpServer { ? activePrompt.signal : AbortSignal.any([activePrompt.signal, appServerLost.signal]); const clearRecoveredSessionFailure = async (handler: CodexEventHandler): Promise => { - await handler.completeSuccessfulTurn(sessionState.currentTurnId); + await handler.completeSuccessfulTurn(activePrompt.currentTurn?.turnId ?? null); const current = sessionState.sessionFailure; if (recoverableSessionFailure !== undefined && current !== undefined @@ -3316,6 +4107,7 @@ export class CodexAcpServer { return this.cancelledPromptResponse(sessionState); }; + let subscribedPrompt = false; try { const sessionReady = this.recovery?.ensureSessionReady(sessionState); if (sessionReady) { @@ -3341,12 +4133,22 @@ export class CodexAcpServer { promptGeneration = this.recovery.generation; removeAppServerLossListener = this.recovery.onGenerationLost(promptGeneration, () => appServerLost.abort()); } + if (this.codexAcpClient.queueSupport.actions.includes("add")) { + await this.assertNoPendingNativeQueue(params.sessionId); + const native = await this.codexAcpClient.appServerClient.threadRead({threadId: params.sessionId}); + if (native.thread.status.type === "active") throw RequestError.invalidRequest("Native queued work is starting or running"); + await this.codexAcpClient.waitForSessionNotifications(params.sessionId); + if (this.queueOwners.get(params.sessionId)?.active()) throw RequestError.invalidRequest("A queued turn is running"); + } const promptEventHandler = new CodexEventHandler( this.connection, sessionState, clientSupportsTypedSessionFailures(this.clientCapabilities), this.sessionFailureEpoch, - sessionState.subagents, + this.codexAcpClient.queueSupport.actions.includes("add") + ? new CodexSubagentEventRouter(params.sessionId, clientSupportsSubagents(this.clientCapabilities), + new ACPSessionConnection(this.connection, params.sessionId), id => this.reportingConnection.reports.releaseOpen(id)) + : sessionState.subagents, (accountUpdated) => this.handleAccountUpdated(accountUpdated), agentFileChangeReportRequest !== null, clientSupportsCompaction(this.clientCapabilities), @@ -3384,6 +4186,7 @@ export class CodexAcpServer { const completesActiveTurn = event.method === "turn/completed" && event.params.threadId === sessionState.sessionId && event.params.turn.id === sessionState.currentTurnId; + if (completesActiveTurn) completedPromptUsage = sessionState.promptTokenUsage?.usage() ?? null; await promptEventHandler.handleNotification(event); if (completesActiveTurn) { // The prompt may remain open for plan approval after its turn has ended. Switch at @@ -3396,6 +4199,7 @@ export class CodexAcpServer { clientSupportsSubagents(this.clientCapabilities), observeInteraction, childThreadId => promptEventHandler.waitForNativeSubagentSession(childThreadId)); + subscribedPrompt = true; if (activePrompt.signal.aborted) { return cancelledPromptResponse(); @@ -3589,6 +4393,7 @@ export class CodexAcpServer { if ( completedPlan !== null && sessionState.collaborationMode === PLAN_COLLABORATION_MODE + && !this.queueOwners.get(params.sessionId)?.active() && !this.promptShouldStop(params.sessionId, activePrompt) ) { const approved = await this.requestPlanImplementationPermission( @@ -3600,7 +4405,10 @@ export class CodexAcpServer { if (this.promptShouldStop(params.sessionId, activePrompt)) { return cancelledPromptResponse(); } - if (approved && !this.promptShouldStop(params.sessionId, activePrompt)) { + if (approved && !this.promptShouldStop(params.sessionId, activePrompt) + && !this.queueOwners.get(params.sessionId)?.active()) { + await this.assertNoPendingNativeQueue(params.sessionId); + promptClient.resumePromptEvents(params.sessionId); await this.applyCollaborationModeChange(sessionState, DEFAULT_COLLABORATION_MODE); const session = new ACPSessionConnection(this.connection, sessionState.sessionId); await session.update({ @@ -3722,8 +4530,8 @@ export class CodexAcpServer { return { stopReason: "end_turn", - usage: this.buildPromptUsage(sessionState), - _meta: this.buildQuotaMeta(sessionState), + usage: this.buildPromptUsage(sessionState, completedPromptUsage), + _meta: this.buildQuotaMeta(sessionState, completedPromptUsage), }; } catch (caught) { const err = this.recovery?.mapError(caught, promptClient) ?? caught; @@ -3774,9 +4582,10 @@ export class CodexAcpServer { // The app-server subscription is session-scoped and outlives this prompt. Flip routing before // awaiting disposal so queued late notifications cannot enter prompt-local buffers. promptNotificationsActive = false; + if (subscribedPrompt) promptClient.finishPromptEvents(params.sessionId); try { await promptClient.waitForSessionNotifications(params.sessionId); - await eventHandler?.finishOutstandingNativeSubagents( + if (!this.queueOwners.get(params.sessionId)?.active()) await eventHandler?.finishOutstandingNativeSubagents( promptWasCancelled || activePrompt.signal.aborted || this.sessionIsClosing(params.sessionId) ? "cancelled" : "failed", @@ -3808,7 +4617,7 @@ export class CodexAcpServer { await eventHandler?.dispose(); disposePromptRequestCancellation(); removeAppServerLossListener(); - sessionState.currentTurnId = null; + if (!this.queueOwners.get(params.sessionId)?.active()) sessionState.currentTurnId = null; const registeredPendingTurnStart = this.pendingTurnStarts.get(params.sessionId); if (registeredPendingTurnStart !== undefined) { this.pendingTurnStarts.delete(params.sessionId); @@ -3922,8 +4731,7 @@ export class CodexAcpServer { } /** `_meta.quota`: the usage of the whole prompt, the same counts as {@link buildPromptUsage}. */ - private buildQuotaMeta(sessionState: SessionState): { quota: QuotaMeta } { - const promptTokenUsage = sessionState.promptTokenUsage?.usage() ?? null; + private buildQuotaMeta(sessionState: SessionState, promptTokenUsage = sessionState.promptTokenUsage?.usage() ?? null): { quota: QuotaMeta } { // Remove the "[reasoning-level]" suffix from currentModelId if present const modelName = sessionState.currentModelId.replace(/\[.*?]$/, ''); @@ -3949,17 +4757,12 @@ export class CodexAcpServer { sessionState.promptTokenUsage = new PromptTokenUsage(startTotal); } - private buildPromptUsage(sessionState: SessionState): acp.Usage | null { - const usage = sessionState.promptTokenUsage?.usage() ?? null; + private buildPromptUsage(sessionState: SessionState, usage = sessionState.promptTokenUsage?.usage() ?? null): acp.Usage | null { return usage === null ? null : toPromptUsage(usage); } - /** - * Runs an app-server operation and maps a failure because the app-server is gone to a clear error. - * It never starts the app-server again: the entry points of user requests do that, see {@link ensureAppServer}. - * `client` is the client that the operation uses, by default the installed one at the call. - */ private async runWithProcessCheck(operation: () => Promise, client = this.codexAcpClient): Promise { + this.assertProviderAvailable(); try { return await operation(); } catch (err) { @@ -3992,8 +4795,32 @@ export class CodexAcpServer { } async cancel(params: acp.CancelNotification): Promise { + // Cancel must enter even while a queue write is pending, but index writes + // must not race its native interrupt/ACK reconciliation. + this.sessionOperations.set(params.sessionId, (this.sessionOperations.get(params.sessionId) ?? 0) + 1); + this.queueCancellations.set(params.sessionId, (this.queueCancellations.get(params.sessionId) ?? 0) + 1); + try { + await this.cancelDuringOperation(params); + } finally { + const cancelling = (this.queueCancellations.get(params.sessionId) ?? 1) - 1; + if (cancelling === 0) this.queueCancellations.delete(params.sessionId); + else this.queueCancellations.set(params.sessionId, cancelling); + const remaining = (this.sessionOperations.get(params.sessionId) ?? 1) - 1; + if (remaining === 0) this.sessionOperations.delete(params.sessionId); + else this.sessionOperations.set(params.sessionId, remaining); + } + } + + private async cancelDuringOperation(params: acp.CancelNotification): Promise { this.activePrompts.get(params.sessionId)?.requestClientCancel(); + this.queueCancelGenerations.set(params.sessionId, (this.queueCancelGenerations.get(params.sessionId) ?? 0) + 1); + this.queueOwners.get(params.sessionId)?.cancel(); const sessionState = this.sessions.get(params.sessionId); + const rawTurnId = this.queueOwners.get(params.sessionId)?.nativeTurnId(); + if (rawTurnId && rawTurnId !== sessionState?.currentTurnId) { + await this.requestTurnInterrupt({threadId: params.sessionId, turnId: rawTurnId}, "Cancel"); + return; + } if (!sessionState) { logger.log("Cancel request rejected: session not found", {sessionId: params.sessionId}); return; diff --git a/src/CodexAppServerClient.ts b/src/CodexAppServerClient.ts index 9a1f95c8d..07297ece8 100644 --- a/src/CodexAppServerClient.ts +++ b/src/CodexAppServerClient.ts @@ -1,3 +1,7 @@ +import type {SessionQueueAction, SessionQueueNative} from "./SessionQueue"; +import type {NativeThreadSearchParams, NativeThreadSearchResponse} from "./SessionDiscovery"; +import type {ThreadAttachmentListParams, ThreadAttachmentListResponse, ThreadAttachmentAddParams, ThreadAttachmentAddResponse, ThreadAttachmentRemoveParams, ThreadAttachmentRemoveResponse} from "./app-server/v2"; +import type {PluginInstalledParams, PluginInstalledResponse, PluginReconcileParams, PluginReconcileResponse} from "./app-server/v2"; import {type MessageConnection, RequestType} from "vscode-jsonrpc/node"; import {AppServerConnectionLostError, ConnectionLoss} from "./app-server-recovery/ConnectionLoss"; import {McpOauthCompletions} from "./mcp/McpOauthCompletions"; @@ -73,6 +77,8 @@ import type { Turn, ThreadResumeParams, ThreadResumeResponse, + ThreadRevertParams, + ThreadRevertResponse, ThreadSettings, ThreadStartParams, ThreadStartResponse, @@ -616,6 +622,14 @@ export class CodexAppServerClient { return await this.sendRequest({ method: "thread/fork", params: params }); } + async threadRevert(params: ThreadRevertParams): Promise { + return await this.sendRequest({method: "thread/revert", params}); + } + + async threadRollback(params: ThreadRollbackParams): Promise { + return await this.sendRequest({method: "thread/rollback", params}); + } + getThreadSettings(threadId: string): ThreadSettings | undefined { return this.threadSettings.get(threadId); } @@ -730,6 +744,26 @@ export class CodexAppServerClient { } } + async threadSearch(params: NativeThreadSearchParams): Promise { + return this.connection.sendRequest("thread/search", {...params, sourceKinds: ["appServer", "cli", "vscode"]}); + } + async threadAttachmentList(params: ThreadAttachmentListParams): Promise { + return this.sendRequest({method: "thread/attachment/list", params}); + } + async threadAttachmentAdd(params: ThreadAttachmentAddParams): Promise { + return this.sendRequest({method: "thread/attachment/add", params}); + } + async threadAttachmentRemove(params: ThreadAttachmentRemoveParams): Promise { + return this.sendRequest({method: "thread/attachment/remove", params}); + } + + async pluginInstalled(params: PluginInstalledParams): Promise { + return this.sendRequest({method: "plugin/installed", params}); + } + async pluginReconcile(params: PluginReconcileParams): Promise { + return this.sendRequest({method: "plugin/reconcile", params}); + } + async threadArchive(params: ThreadArchiveParams): Promise { return await this.sendRequest({ method: "thread/archive", params: params }); } @@ -742,6 +776,26 @@ export class CodexAppServerClient { return await this.sendRequest({ method: "thread/delete", params: params }); } + /** Invalid parameters only: proves method registration without a valid mutation target. */ + async probeQueueAction(action: SessionQueueAction): Promise { + try { await this.connection.sendRequest(`thread/queue/${action}`, {}); return false; } + catch (error) { + return typeof error === "object" && error !== null && "code" in error && error.code === -32600 + && "message" in error && error.message === "Invalid request: missing field `threadId`"; + } + } + + queueNative(): SessionQueueNative { + return { + list: params => this.connection.sendRequest("thread/queue/list", params), + add: params => this.connection.sendRequest("thread/queue/add", params), + update: params => this.connection.sendRequest("thread/queue/update", params), + delete: params => this.connection.sendRequest("thread/queue/delete", params), + reorder: params => this.connection.sendRequest("thread/queue/reorder", params), + start: params => this.connection.sendRequest("thread/queue/start", params), + }; + } + async threadUnsubscribe(params: ThreadUnsubscribeParams): Promise { return await this.sendRequest({ method: "thread/unsubscribe", params: params }); } @@ -1233,7 +1287,11 @@ export type CompactionCompletedNotification = | { method: "thread/compacted", params: Extract["params"] } | { method: "item/completed", params: ItemCompletedNotification & { item: Extract } }; +// Compatibility for older CODEX_PATH binaries only. Current generated API removed rollback. +type ThreadRollbackParams = {threadId: string; numTurns: number}; +type ThreadRollbackResponse = {thread: ThreadReadResponse["thread"]}; type CodexRequest = DistributiveOmit | ThreadBackgroundTerminalsRequest + | {method: "thread/rollback"; params: ThreadRollbackParams}; type DistributiveOmit = T extends any ? Omit diff --git a/src/CodexJsonRpcConnection.ts b/src/CodexJsonRpcConnection.ts index 94fb23bb0..cb9f920bf 100644 --- a/src/CodexJsonRpcConnection.ts +++ b/src/CodexJsonRpcConnection.ts @@ -4,12 +4,22 @@ import type {MessageConnection} from "vscode-jsonrpc/node"; import {spawn, type ChildProcessWithoutNullStreams} from "node:child_process"; import {createRequire} from "node:module"; import crossSpawn from "cross-spawn"; +import type {Socket} from "node:net"; +import {ownedProcessHelperArgs} from "./CodexProcessHelpers"; import {createJSONRPCReader, createJSONRPCWriter} from "./StdUtils"; import {logger} from "./Logger"; const require = createRequire(import.meta.url); +const terminationTimeoutMs = 5_000; +interface OwnedProcess { + groupId?: number; + control?: Socket; + termination?: Promise; +} +const ownedProcesses = new WeakMap(); + export interface CodexConnection { readonly connection: MessageConnection readonly process: ChildProcessWithoutNullStreams; @@ -40,15 +50,29 @@ export function startCodexConnection( const spawnEnv = env ?? process.env; let codex: ChildProcessWithoutNullStreams; - if (codexPath) { + if (process.platform !== "win32") { + const command = codexPath ?? process.execPath; + const args = codexPath ? appServerStartupArgs + : [require.resolve("@openai/codex/bin/codex.js"), ...appServerStartupArgs]; + codex = spawn(process.execPath, ownedProcessHelperArgs("supervisor", [command, ...args]), { + env: spawnEnv, + windowsHide: true, + detached: true, + stdio: ["pipe", "pipe", "pipe", "pipe"], + }) as ChildProcessWithoutNullStreams; + const control = codex.stdio[3] as Socket; + // A broken control pipe is checked by termination via group disappearance. + control.on("error", () => {}); + control.unref(); // This extra lifeline must not keep the adapter event loop alive. + ownedProcesses.set(codex, {...(codex.pid === undefined ? {} : {groupId: codex.pid}), control}); + } else if (codexPath) { // cross-spawn runs `.cmd` shims through cmd.exe with correct escaping for the TOML hook override. - codex = process.platform === "win32" - ? crossSpawn(codexPath, appServerStartupArgs, {env: spawnEnv}) as ChildProcessWithoutNullStreams - : spawn(codexPath, appServerStartupArgs, {env: spawnEnv}); + codex = crossSpawn(codexPath, appServerStartupArgs, {env: spawnEnv, windowsHide: true}) as ChildProcessWithoutNullStreams; } else { const bundledCodexPath = require.resolve("@openai/codex/bin/codex.js"); - codex = spawn(process.execPath, [bundledCodexPath, ...appServerStartupArgs], {env: spawnEnv}); + codex = spawn(process.execPath, [bundledCodexPath, ...appServerStartupArgs], {env: spawnEnv, windowsHide: true}); } + if (!ownedProcesses.has(codex)) ownedProcesses.set(codex, {}); attachLogs(codex); // A write to a process that is dying fails with EPIPE. Without a listener that error would end the adapter. @@ -101,3 +125,68 @@ export function attachLogs(proc: ChildProcessWithoutNullStreams) { logger.log(`[EXIT] code: ${code?.toString()}`); }); } + +/** Stops an unresponsive owned provider before allowing the client to reconnect. */ +export function terminateCodexConnection(native: CodexConnection): Promise { + const child = native.process; + let owned = ownedProcesses.get(child); + if (!owned) { + // Do not infer group ownership from a caller-supplied PID. + owned = {}; + ownedProcesses.set(child, owned); + owned.termination = Promise.reject(new Error("Cannot terminate an unowned provider tree")); + } + // Cache failures as well as successes: retrying taskkill against an old PID is unsafe. + return owned.termination ??= (async () => { + native.connection.dispose(); + if (child.pid === undefined) return; // spawn failed; no OS process was created + if (owned.groupId !== undefined) { + owned.control!.end("stop"); + await waitForTermination(() => hasExited(child) && !groupExists(owned.groupId!)); + } else { + // A reaped Windows launcher no longer proves ownership of its PID/tree. + if (hasExited(child)) throw new Error("Provider exited before tree termination; tree state is unknown"); + await terminateWindowsTree(child.pid); + await waitForTermination(() => hasExited(child)); + } + })(); +} + +function hasExited(child: ChildProcessWithoutNullStreams): boolean { + return child.exitCode !== null || child.signalCode !== null; +} + +function groupExists(groupId: number): boolean { + try { + process.kill(-groupId, 0); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ESRCH") return false; + throw error; + } +} + +async function waitForTermination(stopped: () => boolean): Promise { + const deadline = Date.now() + terminationTimeoutMs; + while (!stopped()) { + if (Date.now() >= deadline) throw new Error("Provider tree termination timed out; tree state is unknown"); + await new Promise(resolve => setTimeout(resolve, 20)); + } +} + +function terminateWindowsTree(pid: number): Promise { + const killer = spawn("taskkill", ["/PID", String(pid), "/T", "/F"], {windowsHide: true, stdio: "ignore"}); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + killer.kill(); + reject(new Error("Provider tree termination timed out; tree state is unknown")); + }, terminationTimeoutMs); + killer.once("error", error => {clearTimeout(timer); reject(error);}); + killer.once("exit", code => { + clearTimeout(timer); + // Launcher exit alone cannot turn a failed tree kill into success. + if (code === 0) resolve(); + else reject(new Error("Provider tree termination failed; tree state is unknown")); + }); + }); +} diff --git a/src/CodexProcessHelpers.ts b/src/CodexProcessHelpers.ts new file mode 100644 index 000000000..2f9673a77 --- /dev/null +++ b/src/CodexProcessHelpers.ts @@ -0,0 +1,88 @@ +import {createRequire} from "node:module"; + +const prefix = "--internal-owned-process-"; +type HelperRole = "supervisor" | "guardian"; + +// Keep this function self-contained: Node/Bun script runtimes execute this fixed +// built-in function with -e; compiled Bun dispatches to the same function below. +// No source code is accepted from arguments or the environment. +function helperMain(require: NodeRequire, role: string, args: string[], compiled: boolean): void { + const {spawn} = require("node:child_process") as typeof import("node:child_process"); + const {fstatSync, closeSync, read} = require("node:fs") as typeof import("node:fs"); + const checks = {pipe(fd: number) { + const stat = fstatSync(fd); + if (!stat.isSocket() && !stat.isFIFO()) throw new Error("Missing private process-helper pipe"); + }}; + if (process.platform === "win32") throw new Error("POSIX process helper required"); + checks.pipe(3); + if (role === "guardian") { + checks.pipe(0); + const group = Number(args[0]); + if (args.length !== 1 || !Number.isSafeInteger(group) || group <= 1 || process.ppid !== group) { + throw new Error("Invalid process-helper parent"); + } + // Our live membership pins this group ID even after the leader exits. + for (const fd of [0, 3]) { + // One byte, EOF, or an error all mean stop. Raw reads also work for + // Bun's inherited pipes, which need not be supported by net.Socket. + read(fd, Buffer.alloc(1), 0, 1, null, () => process.kill(-group, "SIGKILL")); + } + process.stdout.write("ready"); + return; + } + if (role !== "supervisor" || !args[0]) throw new Error("Invalid process-helper invocation"); + // detached spawn must have established a group named after this live PID. + // Refuse to install a guardian when invoked in a caller's shared group. + process.kill(-process.pid, 0); + let provider: import("node:child_process").ChildProcess | undefined; + let pendingSignal: NodeJS.Signals | undefined; + for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) { + process.on(signal, () => { + if (provider) provider.kill(signal); + else pendingSignal = signal; + }); + } + const guardianArgs = compiled + ? ["--internal-owned-process-guardian", String(process.pid)] + : ["-e", `(${helperMain.toString()})(require, "guardian", process.argv.slice(1), false)`, String(process.pid)]; + const guardian = spawn(process.execPath, guardianArgs, {stdio: [3, "pipe", "inherit", "pipe"], windowsHide: true}); + closeSync(3); + guardian.on("error", error => {console.error(error); process.exit(1);}); + guardian.on("exit", () => process.exit(1)); + guardian.stdout!.once("data", () => { + provider = spawn(args[0]!, args.slice(1), {stdio: "inherit", windowsHide: true}); + provider.on("error", error => {console.error(error); process.exit(1);}); + provider.on("exit", (code, signal) => { + if (signal) { + process.removeAllListeners(signal); + process.kill(process.pid, signal); + } else process.exit(code ?? 1); + }); + if (pendingSignal) provider.kill(pendingSignal); + }); +} + +function isCompiledBun(): boolean { + // Bun.main is inside the embedded filesystem only for compiled executables. + const main = (globalThis as typeof globalThis & {Bun?: {main: string}}).Bun?.main; + return main?.startsWith("/$bunfs/") === true || main?.startsWith("B:/~BUN/") === true; +} + +export function ownedProcessHelperArgs(role: HelperRole, args: string[]): string[] { + return isCompiledBun() ? [`${prefix}${role}`, ...args] + : ["-e", `(${helperMain.toString()})(require, ${JSON.stringify(role)}, process.argv.slice(1), false)`, ...args]; +} + +/** Must run before importing the ACP application or reading its configuration. */ +export async function runInternalProcessHelper(): Promise { + const flag = process.argv[2]; + if (!flag?.startsWith(prefix)) return; + try { + helperMain(createRequire(import.meta.url), flag.slice(prefix.length), process.argv.slice(3), true); + // Never fall through into the ACP application while the helper is alive. + await new Promise(() => {}); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exit(1); + } +} diff --git a/src/DesktopAttachmentHistory.ts b/src/DesktopAttachmentHistory.ts index 932ccd473..dbd093b49 100644 --- a/src/DesktopAttachmentHistory.ts +++ b/src/DesktopAttachmentHistory.ts @@ -20,7 +20,7 @@ export function attachmentFileUri(path: string): string | null { return null; } } - return path.startsWith("/") ? pathToFileURL(path).href : null; + return path.startsWith("/") ? pathToFileURL(path, {windows: false}).href : null; } /** Converts the known Desktop attachment envelope into history content blocks. */ diff --git a/src/SessionArchive.ts b/src/SessionArchive.ts new file mode 100644 index 000000000..0884b377f --- /dev/null +++ b/src/SessionArchive.ts @@ -0,0 +1,7 @@ +import {z} from "zod"; +export const SESSION_ARCHIVE_METHOD = "_session/archive"; +export const SESSION_UNARCHIVE_METHOD = "_session/unarchive"; +export const sessionArchiveParser = z.object({sessionId: z.string().trim().min(1)}).strict(); +export function archiveCapability() { + return {version: 1, archiveMethod: SESSION_ARCHIVE_METHOD, unarchiveMethod: SESSION_UNARCHIVE_METHOD, permanentDelete: false}; +} diff --git a/src/SessionDiscovery.ts b/src/SessionDiscovery.ts new file mode 100644 index 000000000..c4c1f2b71 --- /dev/null +++ b/src/SessionDiscovery.ts @@ -0,0 +1,73 @@ +import {RequestError} from "@agentclientprotocol/sdk"; +import {z} from "zod"; +import type {Thread, ThreadAttachmentAddParams, ThreadAttachmentAddResponse, ThreadAttachmentListParams, ThreadAttachmentListResponse, ThreadAttachmentRemoveParams, ThreadAttachmentRemoveResponse} from "./app-server/v2"; + +export const SESSION_SEARCH_METHOD = "_session/search"; +export const SESSION_ATTACHMENTS_METHOD = "_session/attachments"; +const identifier = z.string().min(1).max(4096).refine(value => value.trim().length > 0 && !Array.from(value).some(char => char.charCodeAt(0) < 32)); +const page = {cursor: identifier.nullable().optional(), limit: z.number().int().min(1).max(100).optional()}; +export const sessionSearchParser = z.object({ + searchTerm: z.string().trim().min(1).max(4096), + archived: z.boolean().optional(), + ...page, +}).strict(); +export type SessionSearchRequest = z.infer; +// Typed experimental method omitted from the package's generated subset. Shape +// verified against native 0.160.1 and the public app-server protocol source. +export type NativeThreadSearchParams = SessionSearchRequest; +export type NativeThreadSearchResponse = {data: Array<{thread: Thread, snippet: string}>, nextCursor: string | null, backwardsCursor?: string | null}; + +const attachmentIdentity = {sessionId: identifier, attachmentType: identifier, identityKey: identifier}; +const boundedJson = z.json().refine(value => Buffer.byteLength(JSON.stringify(value), "utf8") <= 256 * 1024, "Attachment payload exceeds 256 KiB"); +export const sessionAttachmentsParser = z.discriminatedUnion("action", [ + z.object({sessionId: identifier, action: z.literal("list"), ...page}).strict(), + z.object({...attachmentIdentity, action: z.literal("add"), payload: boundedJson}).strict(), + z.object({...attachmentIdentity, action: z.literal("remove")}).strict(), +]); +export type SessionAttachmentsRequest = z.infer; +export type SessionDiscoveryClient = { + threadSearch(params: NativeThreadSearchParams): Promise; + threadAttachmentList(params: ThreadAttachmentListParams): Promise; + threadAttachmentAdd(params: ThreadAttachmentAddParams): Promise; + threadAttachmentRemove(params: ThreadAttachmentRemoveParams): Promise; +}; + +export function sessionDiscoveryCapability() { + return { + version: 1, + searchMethod: SESSION_SEARCH_METHOD, + attachmentMethod: SESSION_ATTACHMENTS_METHOD, + attachmentActions: ["list", "add", "remove"], + attachmentsAreMetadata: true, + }; +} + +/** Search is native text search, not a model prompt or a local scan of files. */ +export async function searchSessions(client: Pick, params: SessionSearchRequest) { + const response = await client.threadSearch(sessionSearchParser.parse(params)); + return { + version: 1, + sessions: response.data.map(({thread, snippet}) => ({ + sessionId: thread.id, cwd: thread.cwd, title: thread.name ?? thread.preview, + updatedAt: thread.updatedAt, snippet, + })), + nextCursor: response.nextCursor, + }; +} + +/** These are independently persisted native thread attachments (PR links, + * worktree associations, etc.), not prompt images or disk operations. The host + * remains responsible for actually creating/removing any associated resource. */ +export async function sessionAttachments( + client: Omit, + sessionId: string, + params: SessionAttachmentsRequest, +): Promise<{version: 1, data: unknown}> { + params = sessionAttachmentsParser.parse(params); + if (params.sessionId !== sessionId) throw RequestError.invalidParams(undefined, "Session mismatch"); + switch (params.action) { + case "list": return {version: 1, data: await client.threadAttachmentList({threadId: sessionId, ...(params.cursor === undefined ? {} : {cursor: params.cursor}), ...(params.limit === undefined ? {} : {limit: params.limit})})}; + case "add": return {version: 1, data: await client.threadAttachmentAdd({threadId: sessionId, attachmentType: params.attachmentType, identityKey: params.identityKey, payload: params.payload})}; + case "remove": return {version: 1, data: await client.threadAttachmentRemove({threadId: sessionId, attachmentType: params.attachmentType, identityKey: params.identityKey})}; + } +} diff --git a/src/SessionFileRevert.ts b/src/SessionFileRevert.ts new file mode 100644 index 000000000..5ba556d2a --- /dev/null +++ b/src/SessionFileRevert.ts @@ -0,0 +1,300 @@ +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { lstat, readFile, realpath } from "node:fs/promises"; +import path from "node:path"; +import { RequestError } from "@agentclientprotocol/sdk"; +import { z } from "zod"; +import type { Thread, ThreadItem } from "./app-server/v2"; +import { + createAddedFileGitPatch, + createDeletedFileGitPatch, + createUpdateGitPatch, +} from "./GitPatch"; + +export const SESSION_FILE_REVERT_METHOD = "_session/files/revert"; +export const sessionFileRevertParser = z + .object({ + sessionId: z.string().trim().min(1), + toolCallId: z.string().trim().min(1), + dryRun: z.boolean(), + previewToken: z + .string() + .regex(/^sha256:[a-f0-9]{64}$/) + .optional(), + }) + .strict() + .refine( + (p) => p.dryRun || p.previewToken !== undefined, + "A restore requires its preview token", + ); +export type SessionFileRevertRequest = z.infer; +type NativeFileChange = Extract; +export type FileRevertSession = { + sessionId: string; + cwd: string; + additionalDirectories: string[]; +}; +export type FileRevertResult = { + version: 1; + dryRun: boolean; + canRevert: boolean; + reverted: boolean; + paths: string[]; + previewToken?: string; + reason?: string; +}; +export function fileRevertCapability() { + return { + version: 1, + method: SESSION_FILE_REVERT_METHOD, + dryRun: true, + previewTokenRequired: true, + scope: "nativeFileChangeTool", + requiresGit: true, + changesConversation: false, + coverage: + "Recorded text patches only; not shell changes or a whole-workspace checkpoint", + }; +} + +const hash = (value: string | Buffer) => + createHash("sha256").update(value).digest("hex"); +const MAX_BYTES = 8 * 1024 * 1024; +function inside(root: string, candidate: string): boolean { + const relative = path.relative(root, candidate); + return ( + relative !== "" && + !path.isAbsolute(relative) && + relative !== ".." && + !relative.startsWith(`..${path.sep}`) + ); +} + +/** Ignore inherited alternate-index/worktree knobs. This operation never stages + * files and never changes HEAD, branches, hooks, or the user's Git index. */ +async function git( + cwd: string, + args: string[], + input?: string, +): Promise<{ code: number | null; stdout: string }> { + const env = Object.fromEntries( + Object.entries(process.env).filter( + ([key]) => !key.toUpperCase().startsWith("GIT_"), + ), + ); + return new Promise((resolve, reject) => { + const child = spawn("git", args, { + cwd, + env, + windowsHide: true, + stdio: ["pipe", "pipe", "pipe"], + }); + const chunks: Buffer[] = []; + let size = 0; + const timer = setTimeout(() => child.kill(), 15_000); + child.stdout.on("data", (chunk: Buffer) => { + size += chunk.length; + if (size <= MAX_BYTES) chunks.push(chunk); + else child.kill(); + }); + child.stderr.resume(); + child.stdin.on("error", () => {}); + child.once("error", (error) => { + clearTimeout(timer); + reject(error); + }); + child.once("close", (code) => { + clearTimeout(timer); + resolve({ code, stdout: Buffer.concat(chunks).toString("utf8") }); + }); + child.stdin.end(input); + }); +} + +async function checkedPath( + root: string, + roots: string[], + value: string, +): Promise<{ absolute: string; relative: string }> { + const absolute = path.resolve(value); + if ( + !inside(root, absolute) || + !roots.some((workspace) => inside(workspace, absolute)) + ) + throw new Error("outside_workspace"); + const relative = path.relative(root, absolute); + const parts = relative.split(path.sep); + if (parts.some((part) => part.toLowerCase() === ".git" || part.includes(":"))) + throw new Error("unsafe_path"); + let walk = root; + for (let i = 0; i < parts.length; i++) { + walk = path.join(walk, parts[i]!); + try { + const stat = await lstat(walk); + if ( + stat.isSymbolicLink() || + (i < parts.length - 1 ? !stat.isDirectory() : !stat.isFile()) + ) + throw new Error("unsupported_file_type"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") break; + throw error; + } + } + return { absolute, relative: relative.split(path.sep).join("/") }; +} + +async function fingerprintFiles(files: string[]): Promise { + return Promise.all( + files.map(async (file) => { + try { + const stat = await lstat(file); + if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_BYTES) + throw new Error("unsupported_file_type_or_size"); + return `${file}\0${stat.mode}\0${hash(await readFile(file))}`; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") + return `${file}\0missing`; + throw error; + } + }), + ); +} + +/** Hold the session AND overlapping-workspace mutation fence around this whole + * call. External editors are not controlled by the adapter; Git rejects patch + * conflicts, but this is not a cross-process filesystem transaction. */ +export async function revertSessionFiles( + value: unknown, + session: FileRevertSession, + readHistory: () => Promise<{ thread: Thread }>, +): Promise { + const request = sessionFileRevertParser.parse(value); + if (request.sessionId !== session.sessionId) + throw RequestError.invalidParams(undefined, "Session mismatch"); + const base: FileRevertResult = { + version: 1, + dryRun: request.dryRun, + canRevert: false, + reverted: false, + paths: [], + }; + const { thread } = await readHistory(); + if (thread.id !== session.sessionId) + throw RequestError.invalidParams(undefined, "History session mismatch"); + const matches = thread.turns + .flatMap((turn) => turn.items) + .filter( + (item): item is NativeFileChange => + item.type === "fileChange" && item.id === request.toolCallId, + ); + if (matches.length !== 1 || matches[0]!.status !== "completed") + return { ...base, reason: "completed_patch_not_found" }; + const changes = matches[0]!.changes; + if (!Array.isArray(changes) || changes.length === 0 || changes.length > 64) + return {...base, reason: "unsupported_patch_size"}; + // Validate every native change before constructing any patch. Generated types cannot + // validate runtime JSON from an older/newer native process. + for (const change of changes as unknown[]) { + if (change === null || typeof change !== "object") return {...base, reason: "unsupported_patch"}; + const value = change as {path?: unknown; kind?: {type?: unknown; move_path?: unknown}; diff?: unknown}; + if (typeof value.path !== "string" || !value.path || typeof value.diff !== "string" + || value.kind === null || typeof value.kind !== "object" + || !["add", "delete", "update"].includes(String(value.kind.type)) + || (value.kind.type === "update" && value.kind.move_path != null && typeof value.kind.move_path !== "string")) { + return {...base, reason: "unsupported_patch"}; + } + } + const found = await git(session.cwd, ["rev-parse", "--show-toplevel"]); + if (found.code !== 0) return { ...base, reason: "not_git_repository" }; + const root = await realpath(found.stdout.trim()); + const roots = await Promise.all( + [session.cwd, ...session.additionalDirectories].map((value) => + realpath(value), + ), + ); + let patch = ""; + const paths = new Set(); + try { + for (const change of changes) { + const source = await checkedPath( + root, + roots, + path.resolve(session.cwd, change.path), + ); + paths.add(source.absolute); + let part: string | null; + switch (change.kind.type) { + case "add": + part = createAddedFileGitPatch(source.relative, change.diff); + break; + case "delete": + part = createDeletedFileGitPatch(source.relative, change.diff); + break; + case "update": { + const target = change.kind.move_path + ? await checkedPath( + root, + roots, + path.resolve(session.cwd, change.kind.move_path), + ) + : source; + paths.add(target.absolute); + part = createUpdateGitPatch( + source.relative, + target.relative, + change.diff, + ); + break; + } + default: + return { ...base, reason: "unsupported_patch" }; + } + if (part === null) return { ...base, reason: "unsupported_patch" }; + patch += part; + if (Buffer.byteLength(patch, "utf8") > MAX_BYTES) + return { ...base, reason: "unsupported_patch_size" }; + } + base.paths = [...paths].sort(); + const before = await fingerprintFiles(base.paths); + const previewToken = `sha256:${hash(JSON.stringify([root, session.sessionId, request.toolCallId, patch, before]))}`; + if (!request.dryRun && request.previewToken !== previewToken) + return { ...base, reason: "stale_preview" }; + const check = await git( + root, + ["apply", "--reverse", "--check", "--whitespace=nowarn", "-"], + patch, + ); + if (check.code !== 0) return { ...base, reason: "patch_conflict" }; + if (request.dryRun) return { ...base, canRevert: true, previewToken }; + // Validate nodes again immediately before applying, including parents. + for (const file of base.paths) await checkedPath(root, roots, file); + if ( + JSON.stringify(before) !== + JSON.stringify(await fingerprintFiles(base.paths)) + ) + return { ...base, reason: "stale_preview" }; + const applied = await git( + root, + ["apply", "--reverse", "--whitespace=nowarn", "-"], + patch, + ); + if (applied.code !== 0) + return { ...base, reason: "restore_outcome_unknown" }; + return { ...base, canRevert: true, reverted: true }; + } catch (error) { + // Never include file contents or provider stderr in a failure response. + if ( + error instanceof Error && + [ + "outside_workspace", + "unsafe_path", + "unsupported_file_type", + "unsupported_file_type_or_size", + ].includes(error.message) + ) { + return { ...base, reason: error.message }; + } + throw error; + } +} diff --git a/src/SessionIndexService.ts b/src/SessionIndexService.ts index 2b27db1c8..712ecde7a 100644 --- a/src/SessionIndexService.ts +++ b/src/SessionIndexService.ts @@ -57,6 +57,10 @@ export interface SessionIndexHost { closeSession(sessionId: string): Promise; beginSessionCloseFence(sessionId: string): void; endSessionCloseFence(sessionId: string): void; + /** Rejects archive while autonomous queue work or an uncertain queue outcome still owns the session. */ + assertSessionArchiveAllowed?(sessionId: string): void; + /** Reserves the session against native queue writes and cancellation in flight. */ + runSessionMutation?(sessionId: string, operation: () => Promise): Promise; } type SessionPage = {sessions: acp.SessionInfo[], nextCursor: string | null}; @@ -189,9 +193,12 @@ export class SessionIndexService { /** `_session/rename`. */ async rename({sessionId, title}: SessionRenameRequest): Promise> { this.require(SESSION_RENAME_METHOD); - await this.host.ensureAppServer(); - await this.titles.rename(sessionId, title); - return {}; + const rename = async () => { + await this.host.ensureAppServer(); + await this.titles.rename(sessionId, title); + return {}; + }; + return this.host.runSessionMutation ? this.host.runSessionMutation(sessionId, rename) : rename(); } /** @@ -202,8 +209,16 @@ export class SessionIndexService { */ async setArchived({sessionId}: SessionArchiveRequest, archived: boolean): Promise> { this.require(archived ? SESSION_ARCHIVE_METHOD : SESSION_UNARCHIVE_METHOD); + const mutate = () => this.setArchivedDuringOperation(sessionId, archived); + return this.host.runSessionMutation ? this.host.runSessionMutation(sessionId, mutate) : mutate(); + } + + private async setArchivedDuringOperation(sessionId: string, archived: boolean): Promise> { + this.host.assertSessionArchiveAllowed?.(sessionId); logger.log(archived ? "Archiving session..." : "Unarchiving session...", {sessionId}); await this.host.ensureAppServer(); + // Native work can arrive while readiness is pending. Recheck before closing or archiving it. + this.host.assertSessionArchiveAllowed?.(sessionId); // Read before the fence, which counts as open. const open = this.host.hasLocalSession(sessionId); // An open of the session that starts meanwhile would load the thread that Codex archives. diff --git a/src/SessionQueue.ts b/src/SessionQueue.ts new file mode 100644 index 000000000..722ef530f --- /dev/null +++ b/src/SessionQueue.ts @@ -0,0 +1,200 @@ +import {RequestError} from "@agentclientprotocol/sdk"; +import {z} from "zod"; +import type {Turn} from "./app-server/v2/Turn"; + +export const SESSION_QUEUE_METHOD = "_session/queue"; +export const SESSION_QUEUE_ACTIONS = ["list", "add", "update", "delete", "reorder", "start"] as const; +export type SessionQueueAction = typeof SESSION_QUEUE_ACTIONS[number]; + +// A conservative tested floor, NOT a claim about the first native release. +// Source: codex-rs/app-server-protocol/src/protocol/v2/thread.rs:926-1027; +// experimental registrations: protocol/common.rs:630-664. Not generated types. +export const SESSION_QUEUE_MIN_NATIVE_VERSION = "0.160.1"; + +const identifier = z.string().refine(value => value.trim().length > 0 && !/[\u0000-\u001f\u007f]/u.test(value)); +const byteOffset = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER); +const textElement = z.strictObject({ + byteRange: z.strictObject({start: byteOffset, end: byteOffset}), + placeholder: z.string().nullable().optional(), +}); +const detail = z.enum(["auto", "low", "high", "original"]).nullable().optional(); + +// Preserve native UserInput, including snake_case text_elements and omitted +// serde-default fields. No ACP text/image conversion, defaults, or dropped keys. +// Source: protocol/v2/turn.rs:382-474. New variants require an explicit review. +const nativeInput = z.union([ + z.strictObject({type: z.literal("text"), text: z.string(), text_elements: z.array(textElement).optional()}), + z.strictObject({type: z.literal("image"), url: z.string(), detail}), + z.strictObject({type: z.literal("image"), fileId: z.string(), detail}), + z.strictObject({type: z.literal("localImage"), path: z.string(), detail}), + z.strictObject({type: z.literal("audio"), url: z.string()}), + z.strictObject({type: z.literal("localAudio"), path: z.string()}), + z.strictObject({type: z.literal("skill"), name: z.string(), path: z.string()}), + z.strictObject({type: z.literal("mention"), name: z.string(), path: z.string()}), +]); +export type NativeQueueInput = z.infer; + +const session = {sessionId: identifier}; +const requestSchema = z.discriminatedUnion("action", [ + z.strictObject({ + ...session, action: z.literal("list"), cursor: z.string().nullable().optional(), + limit: z.number().int().min(0).max(0xffff_ffff).nullable().optional(), + }), + z.strictObject({ + ...session, action: z.literal("add"), input: z.array(nativeInput), clientUserMessageId: identifier, + }), + z.strictObject({ + ...session, action: z.literal("update"), queuedSubmissionId: identifier, input: z.array(nativeInput), + }), + z.strictObject({...session, action: z.literal("delete"), queuedSubmissionId: identifier}), + z.strictObject({...session, action: z.literal("reorder"), queuedSubmissionIds: z.array(identifier)}), + z.strictObject({...session, action: z.literal("start"), queuedSubmissionId: identifier.nullable().optional()}), +]); +export type SessionQueueRequest = z.infer; + +export function parseSessionQueueRequest(value: unknown): SessionQueueRequest { + const parsed = requestSchema.safeParse(value); + if (!parsed.success) { + // Do not echo prompt content, attachment URLs, or unknown injected fields. + throw RequestError.invalidParams(undefined, "Invalid session queue request"); + } + return parsed.data; +} + +export type NativeQueuedSubmission = { + id: string; + input: NativeQueueInput[]; + clientUserMessageId: string; + [key: string]: unknown; +}; +export type NativeQueueListResponse = {data: NativeQueuedSubmission[]; nextCursor: string | null; [key: string]: unknown}; +export type NativeQueueWriteResponse = {queuedSubmission: NativeQueuedSubmission; [key: string]: unknown}; +export type NativeQueueDeleteResponse = {deleted: boolean; [key: string]: unknown}; +export type NativeQueueReorderResponse = Record; +export type NativeQueueStartResponse = {turn: Turn; [key: string]: unknown}; + +type NativeParams = + Omit, "sessionId" | "action"> & {threadId: string}; + +/** No caller-selectable RPC method or free-form native params escape hatch. */ +export interface SessionQueueNative { + list(params: NativeParams<"list">): Promise; + add(params: NativeParams<"add">): Promise; + update(params: NativeParams<"update">): Promise; + delete(params: NativeParams<"delete">): Promise; + reorder(params: NativeParams<"reorder">): Promise; + /** This starts real generation. The owning server must attach turn handlers first. */ + start(params: NativeParams<"start">): Promise; +} + +export type SessionQueueSupport = { + readonly nativeVersion: string | null; + readonly actions: readonly SessionQueueAction[]; +}; + +function eligibleNativeVersion(version: string | null | undefined): boolean { + // Only an actual normalized stable native version. An npm package version, + // missing version, or unverified prerelease is not support evidence. + const match = version?.match(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/u); + if (!match) return false; + const [major, minor, patch] = match.slice(1).map(Number); + if (![major, minor, patch].every(value => Number.isSafeInteger(value))) return false; + return major! > 0 || minor! > 160 || (minor === 160 && patch! >= 1); +} + +/** + * The integrator supplies per-action POSITIVE evidence from the running native + * process (e.g. its actual experimental schema, or a side-effect-free parameter + * validation probe). A list-only probe proves only list. Never call a valid + * add/update/delete/reorder/start to probe, and never treat an arbitrary -32602, + * auth/store/network error, or package version as proof of method support. + * Cache only for that native process generation; re-probe on restart/override. + */ +export async function probeSessionQueueSupport( + nativeVersion: string | null | undefined, + probeAction: (action: SessionQueueAction) => Promise, +): Promise { + const actions: SessionQueueAction[] = []; + if (eligibleNativeVersion(nativeVersion)) { + for (const action of SESSION_QUEUE_ACTIONS) { + try { + if (await probeAction(action) === true) actions.push(action); + } catch { + // Absence of affirmative evidence leaves this action unadvertised. + } + } + } + return Object.freeze({nativeVersion: nativeVersion ?? null, actions: Object.freeze(actions)}); +} + +export function sessionQueueCapability(support: SessionQueueSupport) { + if (!eligibleNativeVersion(support.nativeVersion)) return undefined; + const actions = SESSION_QUEUE_ACTIONS.filter(action => support.actions.includes(action)); + if (actions.length === 0) return undefined; + return {version: 1 as const, method: SESSION_QUEUE_METHOD, actions, + ...(actions.includes("add") ? {changedNotification: "_session/queue/changed", turnNotification: "_session/queue/turn"} : {}), + }; +} + +export type SessionQueueResponse = + | {status: "unsupported"} + | {status: "ok"; result: NativeQueueListResponse | NativeQueueWriteResponse | + NativeQueueDeleteResponse | NativeQueueReorderResponse | NativeQueueStartResponse}; + +/** + * Must run INSIDE the server's lifecycle reservation for this exact session and + * native process: serialize with prompt, rewind, close, delete, provider change, + * and queue operations until the native promise settles. A timeout/transport + * loss does not imply no mutation; the caller must reconcile, never auto-retry. + * This module has no transport, process launching, locks, or event ownership. + */ +export async function runSessionQueue( + value: unknown, + dependencies: {sessionId: string; support: SessionQueueSupport; native: SessionQueueNative}, +): Promise { + const request = parseSessionQueueRequest(value); + if (request.sessionId !== dependencies.sessionId) { + throw RequestError.invalidParams(undefined, "Queue request does not target the bound session"); + } + const capability = sessionQueueCapability(dependencies.support); + if (!capability?.actions.includes(request.action)) return {status: "unsupported"}; + const threadId = dependencies.sessionId; + const native = dependencies.native; + try { + switch (request.action) { + case "list": + return {status: "ok", result: await native.list({ + threadId, + ...("cursor" in request ? {cursor: request.cursor} : {}), + ...("limit" in request ? {limit: request.limit} : {}), + })}; + case "add": + return {status: "ok", result: await native.add({threadId, input: request.input, clientUserMessageId: request.clientUserMessageId})}; + case "update": + return {status: "ok", result: await native.update({threadId, queuedSubmissionId: request.queuedSubmissionId, input: request.input})}; + case "delete": + return {status: "ok", result: await native.delete({threadId, queuedSubmissionId: request.queuedSubmissionId})}; + case "reorder": + return {status: "ok", result: await native.reorder({threadId, queuedSubmissionIds: request.queuedSubmissionIds})}; + case "start": + return {status: "ok", result: await native.start({ + threadId, + ...("queuedSubmissionId" in request ? {queuedSubmissionId: request.queuedSubmissionId} : {}), + })}; + } + } catch (error) { + // Native -32600 is its definitive pre-mutation invalid-request response. + // Translate to ACP's error type so its router preserves the validation + // failure rather than wrapping vscode-jsonrpc ResponseError as -32603. + if (typeof error === "object" && error !== null && "code" in error + && (error.code === -32600 || error.code === -32602)) { + throw RequestError.invalidParams(undefined, "Native queue rejected this request; refresh the queue and retry with current IDs"); + } + // A definitive JSON-RPC method-not-found is safe to report as unsupported. + // No other error is converted to success/unsupported and nothing retries. + if (typeof error === "object" && error !== null && "code" in error && error.code === -32601) { + return {status: "unsupported"}; + } + throw error; + } +} diff --git a/src/SessionRewind.ts b/src/SessionRewind.ts new file mode 100644 index 000000000..1b7f0532c --- /dev/null +++ b/src/SessionRewind.ts @@ -0,0 +1,123 @@ +import {createHash} from "node:crypto"; +import {RequestError} from "@agentclientprotocol/sdk"; +import type {CodexAppServerClient} from "./CodexAppServerClient"; +import type {Thread, Turn} from "./app-server/v2"; +import {userInputVisibleText} from "./UserInputContent"; + +export const SESSION_REWIND_METHOD = "_session/rewind"; +export const SESSION_REWIND_CAPABILITY = "sessionRewind"; + +export type SessionHistoryPoint = { + messageId: string; + messageFingerprint: string; + messageOccurrence: number; +}; +export type SessionRewindRequest = { + sessionId: string; + beforeMessage: SessionHistoryPoint; + resumeAtMessage?: SessionHistoryPoint; +}; +export type SessionRewindResponse = {rewound: boolean}; + +type RewindHooks = { + /** Called only after validation. Stops an active prompt and awaits its cleanup. */ + beforeMutation?: () => Promise; + mutationStarted?: () => void; +}; + +export async function rewindSession( + request: SessionRewindRequest, + client: CodexAppServerClient, + hooks: RewindHooks = {}, +): Promise { + const read = async () => { + const {thread} = await client.threadReadWithHistory(request.sessionId); + if (thread.id !== request.sessionId) throw RequestError.invalidParams(undefined, "Rewind history session changed"); + if (thread.historyMode !== "legacy" && thread.historyMode !== "paginated") { + throw RequestError.invalidParams(undefined, "Unknown history mode; rewind requires an explicit native history mode"); + } + return thread; + }; + let history = await read(); + const target = resolveTarget(request, history); + const prefix = history.turns.slice(0, target.turnIndex); + if (hooks.beforeMutation) { + await hooks.beforeMutation(); + // Cancellation may append the final interrupted items. Re-read the target and retained + // prefix, rather than applying a count computed before prompt settlement. + history = await read(); + const current = resolveTarget(request, history); + if (current.turn.id !== target.turn.id || historyKey(history.turns.slice(0, current.turnIndex)) !== historyKey(prefix)) { + throw RequestError.invalidParams(undefined, "Rewind history changed while settling the prompt; reload history"); + } + } + hooks.mutationStarted?.(); + // This choice is made from native metadata, NEVER from a caught error or a missing response field. + if (history.historyMode === "legacy") { + await client.threadRollback({threadId: request.sessionId, numTurns: history.turns.length - target.turnIndex}); + } else { + await client.threadRevert({threadId: request.sessionId, beforeTurnId: target.turn.id}); + } + // thread/revert returns turns:[] even for a nonempty prefix. Read persisted native history. + const after = await read(); + if (historyKey(after.turns) !== historyKey(prefix)) { + throw RequestError.internalError(undefined, "Native rewind did not retain the expected prefix; reload history before continuing"); + } + return {rewound: true}; +} + +function resolveTarget(request: SessionRewindRequest, history: Thread) { + const users = history.turns.flatMap((turn, turnIndex) => turn.items.flatMap((item, itemIndex) => + item.type === "userMessage" ? [{turn, turnIndex, item, itemIndex}] : [])); + const point = request.beforeMessage; + validatePoint(point); + const exact = messageIdCandidates(point.messageId).map(id => users.find(({item}) => item.id === id)).find(Boolean); + const matches = () => users.filter(({item}) => fingerprint(userInputVisibleText(item.content)) === point.messageFingerprint); + const candidates = exact ? undefined : matches(); + const match = exact ?? candidates?.[point.messageOccurrence - 1]; + if (!match) throw RequestError.invalidParams({messageId: point.messageId}, `Rewind message ${point.messageId} was not found in session ${request.sessionId}`); + if (fingerprint(userInputVisibleText(match.item.content)) !== point.messageFingerprint) { + throw RequestError.invalidParams(undefined, "Rewind message fingerprint changed; reload history"); + } + if (match.itemIndex !== 0) throw RequestError.invalidParams(undefined, `Rewind message ${point.messageId} does not start a turn`); + // Text-only fallback cannot identify content omitted by ACP replay (e.g. audio/resource links). + if (!exact && (userInputVisibleText(match.item.content).length === 0 || match.item.content.some(input => + input.type === "audio" || input.type === "localAudio" || input.type === "mention" || input.type === "localImage" + || (input.type === "text" && input.text.startsWith("# Files "))))) { + throw RequestError.invalidParams(undefined, "Rewind attachment identity requires a current message ID; reload history"); + } + const retained = history.turns.slice(0, match.turnIndex); + const assistants = retained.flatMap(turn => turn.items.flatMap(item => item.type === "agentMessage" ? [item] : [])); + if (request.resumeAtMessage) { + const anchor = request.resumeAtMessage; + validatePoint(anchor); + const last = assistants.at(-1); + if (!last || fingerprint(last.text) !== anchor.messageFingerprint + || !messageIdCandidates(anchor.messageId).includes(last.id)) { + throw RequestError.invalidParams(undefined, "Rewind retained boundary changed; reload history"); + } + } else if (!exact && (candidates?.length ?? 0) > 1) { + throw RequestError.invalidParams(undefined, "Repeated rewind fingerprint requires a current retained boundary; reload history"); + } + return match; +} + +function validatePoint(point: SessionHistoryPoint): void { + if (!point || typeof point.messageId !== "string" || !point.messageId.trim() + || !/^sha256:[0-9a-f]{64}$/.test(point.messageFingerprint) + || !Number.isSafeInteger(point.messageOccurrence) || point.messageOccurrence < 1) { + throw RequestError.invalidParams(undefined, "Invalid rewind history point"); + } +} + +function historyKey(turns: Turn[]): string { + // Runtime status/timing can change during shutdown; the retained IDs and items must not. + return JSON.stringify(turns.map(turn => [turn.id, turn.items])); +} +function fingerprint(text: string): string { + return `sha256:${createHash("sha256").update(text, "utf8").digest("hex")}`; +} +function messageIdCandidates(messageId: string): string[] { + const protocolMessageId = messageId.replace(/:segment:\d+$/, ""); + return protocolMessageId === messageId ? [messageId] : [messageId, protocolMessageId]; +} diff --git a/src/SessionRuntime.ts b/src/SessionRuntime.ts new file mode 100644 index 000000000..7b0c692f9 --- /dev/null +++ b/src/SessionRuntime.ts @@ -0,0 +1,119 @@ +import {RequestError} from "@agentclientprotocol/sdk"; +import {z} from "zod"; +import type {CodexAcpClient} from "./CodexAcpClient"; +import type {SessionState} from "./CodexAcpServer"; +import {listMcpServerStatus} from "./mcp/McpServerStatusList"; + +export const RUNTIME_READ_METHOD = "_session/runtime/read"; +export const RUNTIME_CONTROL_METHOD = "_session/runtime/control"; +export const runtimeReadParser = z.object({ + sessionId: z.string().trim().min(1), + resource: z.enum(["context", "usage", "mcp", "commands", "plugins"]), +}).strict(); +export const runtimeControlParser = z.object({ + sessionId: z.string().trim().min(1), + action: z.enum(["reloadSkills", "reconnectMcp", "reloadPlugins"]), +}).strict(); +export type RuntimeReadRequest = z.infer; +export type RuntimeControlRequest = z.infer; +export type RuntimeResponse = + | {version: 1, status: "ok", data: unknown} + | {version: 1, status: "unavailable", reason: "timeout" | "cancelled" | "stale"}; + +export function runtimeCapability() { + return { + version: 1, + readMethod: RUNTIME_READ_METHOD, + controlMethod: RUNTIME_CONTROL_METHOD, + reads: ["context", "usage", "mcp", "commands", "plugins"], + controls: ["reloadSkills", "reconnectMcp", "reloadPlugins"], + }; +} + +export type RuntimeSession = Pick; +type RuntimeClient = Pick; + +/** Do not synthesize prompt categories from aggregate token usage. Null means + * the native runtime has not supplied the measurement, not zero tokens. */ +export async function readRuntime( + client: RuntimeClient, + session: RuntimeSession, + request: RuntimeReadRequest, + signal: AbortSignal, + isCurrent: () => boolean = () => true, + timeoutMs = 5000, +): Promise { + if (request.sessionId !== session.sessionId) throw RequestError.invalidParams(undefined, "Session mismatch"); + if (signal.aborted) return {version: 1, status: "unavailable", reason: "cancelled"}; + if (!isCurrent()) return {version: 1, status: "unavailable", reason: "stale"}; + const operation = async (): Promise => { + switch (request.resource) { + case "context": return { + model: session.currentModelId, + totalTokens: session.lastTokenUsage?.totalTokens ?? null, + maxTokens: session.modelContextWindow, + categories: null, + source: "last_native_usage_event", + }; + case "usage": return {last: session.lastTokenUsage, total: session.totalTokenUsage, rateLimits: session.rateLimits}; + case "plugins": return await client.installedPlugins([session.cwd, ...session.additionalDirectories]); + case "commands": return await client.listSkills({cwds: [session.cwd, ...session.additionalDirectories]}); + case "mcp": { + // The existing paginated reader takes only listMcpServers in practice. + const servers = await listMcpServerStatus(client as CodexAcpClient, session.sessionId); + return servers.map(server => ({ + name: server.name, + status: server.runtimeStatus, + authStatus: server.authStatus, + pluginId: server.pluginId, + serverInfo: server.serverInfo, + toolNames: Object.keys(server.tools), + })); + } + } + }; + let timer: ReturnType | undefined; + let onAbort: (() => void) | undefined; + try { + const result = await Promise.race([ + operation().then(data => ({version: 1, status: "ok", data})), + new Promise(resolve => { + timer = setTimeout(() => resolve({version: 1, status: "unavailable", reason: "timeout"}), timeoutMs); + timer.unref?.(); + }), + new Promise(resolve => { + onAbort = () => resolve({version: 1, status: "unavailable", reason: "cancelled"}); + signal.addEventListener("abort", onAbort, {once: true}); + }), + ]); + return isCurrent() ? result : {version: 1, status: "unavailable", reason: "stale"}; + } finally { + if (timer) clearTimeout(timer); + if (onAbort) signal.removeEventListener("abort", onAbort); + } +} + +/** The server must retain its lifecycle lease until native completion. + * reloadMcpServers is process-wide: use the provider barrier, not just a + * per-session guard, and do not claim the connections are ready from its ack. */ +export async function controlRuntime( + client: RuntimeClient, + session: RuntimeSession, + request: RuntimeControlRequest, +): Promise { + if (request.sessionId !== session.sessionId) throw RequestError.invalidParams(undefined, "Session mismatch"); + switch (request.action) { + case "reloadSkills": return { + version: 1, status: "ok", + data: await client.listSkills({cwds: [session.cwd, ...session.additionalDirectories], forceReload: true}), + }; + case "reloadPlugins": + return {version: 1, status: "ok", data: {...await client.reconcilePlugins(), scope: "provider", runtimeReady: false}}; + case "reconnectMcp": + await client.reloadMcpServers(); + return {version: 1, status: "ok", data: {completed: true, scope: "provider", connectionsReady: false}}; + } +} diff --git a/src/TitleGenerator.ts b/src/TitleGenerator.ts index 770a60819..9d1ef4b11 100644 --- a/src/TitleGenerator.ts +++ b/src/TitleGenerator.ts @@ -36,6 +36,11 @@ const runTitleWriteNow: SerializeTitleWrite = (_title, write) => write(); export class TitleGenerator { private generated = false; + private existingTitle = false; + private generation = 0; + private retired = false; + /** Kept even on failure: a lost acknowledgement does not prove the native write was cancelled. */ + private dispatchedWrite: Promise | null = null; /** The generated title was dropped for an explicit title, see {@link retryAfterFailedRename}. */ private droppedForExplicitTitle = false; private inFlight: Promise | null = null; @@ -54,10 +59,43 @@ export class TitleGenerator { * Prevents any future generation since a human-set or prior AI title exists. */ markExistingTitle(): void { + this.existingTitle = true; + this.generation++; this.generated = true; this.droppedForExplicitTitle = false; } + /** + * Call in rewind's beforeMutation hook, after the active prompt has settled, while new prompts are + * excluded. Invalidates pending generation and queued callbacks without waiting for the title model. + * A dispatched rename must acknowledge before history can change; failure rejects this hook (and + * later attempts), even though ordinary title generation is best-effort. Do not swallow that error. + * Existing names are kept; work discarded before dispatch may retry on a later completed turn. + */ + async invalidateForRewind(): Promise { + this.generation++; + this.generated = this.existingTitle || this.dispatchedWrite !== null; + this.droppedForExplicitTitle = false; + this.inFlight = null; + this.observeRename(); + try { + await this.dispatchedWrite; + } catch (cause) { + throw new Error("Automatic title write was not acknowledged; rewind was not dispatched", {cause}); + } + } + + /** + * A replaced/closed SessionState cannot start new work. Existing work keeps the upstream + * load/title behavior, but its drain obligation belongs to the session until acknowledged. + * Unlike waitForIdle, this is unbounded and preserves native write failures. + */ + async retire(): Promise { + this.retired = true; + await this.inFlight; + await this.dispatchedWrite; + } + /** * Call when an explicit rename failed. A title that this generator dropped for that rename is generated * again after the next turn, as if the rename had never come. @@ -87,13 +125,13 @@ export class TitleGenerator { * not turn.items — turn.items contains only agent output). */ onTurnCompleted(userPromptText: string): void { - if (this.generated) return; + if (this.retired || this.generated) return; const src = this.getSessionTitleSource(); // "explicit": user renamed or session loaded with a name — skip // "unknown": resumed session with indeterminate history — skip if (src === "explicit" || src === "unknown") return; this.generated = true; - const run = this.generateAndPersist(userPromptText) + const run = this.generateAndPersist(userPromptText, this.generation) .catch(() => { // title generation is best-effort; never surface errors to the user }) @@ -128,7 +166,7 @@ export class TitleGenerator { } } - private async generateAndPersist(userPromptText: string): Promise { + private async generateAndPersist(userPromptText: string, generation: number): Promise { if (!userPromptText.trim()) return; // Ephemeral thread: not persisted to disk, not visible in thread list, @@ -137,6 +175,7 @@ export class TitleGenerator { cwd: this.cwd, ephemeral: true, }); + if (generation !== this.generation) return; const turnResult = await this.client.runTurn({ threadId: epThread.id, @@ -148,6 +187,7 @@ export class TitleGenerator { outputSchema: TITLE_OUTPUT_SCHEMA, model: TITLE_MODEL, }); + if (generation !== this.generation) return; const title = extractTitle(turnResult.turn); if (!title) return; @@ -160,14 +200,19 @@ export class TitleGenerator { } const written = await this.serializeTitleWrite(title, async () => { + // Invalidation can happen while this callback waits in the session-index write queue. + if (generation !== this.generation) return false; // An explicit rename that came while this write waited for its turn wins. if (this.getSessionTitleSource() === "explicit") return false; - await this.client.threadSetName({ + this.dispatchedWrite = this.client.threadSetName({ threadId: this.mainThreadId, name: title, }); + await this.dispatchedWrite; return true; }); + // An obsolete callback must not change retry/echo state belonging to a later generation. + if (generation !== this.generation) return; if (!written) { this.droppedForExplicitTitle = true; return; diff --git a/src/UserInputContent.ts b/src/UserInputContent.ts new file mode 100644 index 000000000..37cc65d61 --- /dev/null +++ b/src/UserInputContent.ts @@ -0,0 +1,50 @@ +import type * as acp from "@agentclientprotocol/sdk"; +import type {UserInput} from "./app-server/v2"; +import {attachmentFileUri, desktopAttachmentHistory} from "./DesktopAttachmentHistory"; + +export function userInputToContentBlocks(input: UserInput): acp.ContentBlock[] { + switch (input.type) { + case "text": + return desktopAttachmentHistory(input.text) + ?? (input.text.length > 0 ? [{ type: "text", text: input.text }] : []); + case "image": + return [{ + type: "text", + text: "url" in input + ? formatUriAsLink("image", input.url) + : `image:${input.fileId}`, + }]; + case "localImage": + case "localAudio": + case "mention": { + const uri = attachmentFileUri(input.path); + const fileName = input.path.split(/[\\/]/).pop() || input.type; + const name = input.type === "mention" && input.name.trim().length > 0 ? input.name : fileName; + return uri !== null + ? [{type: "resource_link", name, uri}] + : [{type: "text", text: formatUriAsLink(name, input.path)}]; + } + case "skill": + return [{ type: "text", text: `skill:${input.name} (${input.path})` }]; + case "audio": + return [{type: "text", text: formatUriAsLink("audio", input.url)}]; + } +} + +function formatUriAsLink(name: string | null, uri: string): string { + if (name && name.length > 0) { + return `[@${name}](${uri})`; + } + if (uri.startsWith("file://")) { + const path = uri.replace("file://", ""); + const fileName = path.split("/").pop() ?? path; + return `[@${fileName}](${uri})`; + } + return uri; +} + +export function userInputVisibleText(content: UserInput[]): string { + return content.flatMap(userInputToContentBlocks) + .filter((block): block is Extract => block.type === "text") + .map(block => block.text).join(""); +} diff --git a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts index 043cc54e4..ea020e2bd 100644 --- a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts +++ b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts @@ -1,3 +1,4 @@ +import path from "node:path"; // noinspection ES6RedundantAwait import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; @@ -498,7 +499,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { }); expect(extraRootsSetSpy).toHaveBeenCalledWith({ - extraRoots: ["/skills/one/.agents/skills", "/skills/two/.agents/skills"], + extraRoots: [path.join("/skills/one", ".agents", "skills"), path.join("/skills/two", ".agents", "skills")], }); expect(extraRootsSetSpy.mock.invocationCallOrder[0]!).toBeLessThan(threadStartSpy.mock.invocationCallOrder[0]!); expect(listSkillsSpy).not.toHaveBeenCalled(); @@ -591,7 +592,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { expect(session.additionalDirectories).toEqual(["/workspace/extra"]); expect(extraRootsSetSpy).toHaveBeenCalledWith({ - extraRoots: ["/workspace/extra/.agents/skills"], + extraRoots: [path.join("/workspace/extra", ".agents", "skills")], }); expect(extraRootsSetSpy.mock.invocationCallOrder[0]!).toBeLessThan(threadStartSpy.mock.invocationCallOrder[0]!); expect(listSkillsSpy).not.toHaveBeenCalled(); @@ -1442,7 +1443,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { }); expect(extraRootsSetSpy).toHaveBeenCalledWith({ - extraRoots: ["/workspace/extra/.agents/skills"], + extraRoots: [path.join("/workspace/extra", ".agents", "skills")], }); expect(listSkillsSpy).not.toHaveBeenCalledWith(expect.objectContaining({forceReload: true})); expect(turnStartSpy.mock.calls[0]![0].sandboxPolicy).toMatchObject({ diff --git a/src/__tests__/CodexACPAgent/data/session-index-capabilities.json b/src/__tests__/CodexACPAgent/data/session-index-capabilities.json index 69ecdb3ce..b5894a79e 100644 --- a/src/__tests__/CodexACPAgent/data/session-index-capabilities.json +++ b/src/__tests__/CodexACPAgent/data/session-index-capabilities.json @@ -10,6 +10,7 @@ "rawInputRendering", "planContentDelta", "codexHooks", + "sessionRewind", "sessionIndex", "sessionArchive", "sessionRename", @@ -25,7 +26,8 @@ "recommendedValue", "rawInputRendering", "planContentDelta", - "codexHooks" + "codexHooks", + "sessionRewind" ], "plain": null } diff --git a/src/__tests__/CodexACPAgent/initialize.test.ts b/src/__tests__/CodexACPAgent/initialize.test.ts index ad567e9c3..8a82a83a0 100644 --- a/src/__tests__/CodexACPAgent/initialize.test.ts +++ b/src/__tests__/CodexACPAgent/initialize.test.ts @@ -1,3 +1,7 @@ +import {fileRevertCapability} from "../../SessionFileRevert"; +import {sessionDiscoveryCapability} from "../../SessionDiscovery"; +import {archiveCapability} from "../../SessionArchive"; +import {runtimeCapability} from "../../SessionRuntime"; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { CodexAcpServer } from '../../CodexAcpServer'; import * as acp from '@agentclientprotocol/sdk'; @@ -67,8 +71,13 @@ describe('CodexACPAgent - initialize', () => { }, authMethods: getCodexAuthMethods(), _meta: { + runtime: runtimeCapability(), + archive: archiveCapability(), + fileRevert: fileRevertCapability(), + discovery: sessionDiscoveryCapability(), steering: { supported: true, + idleBehavior: ["promptRequired"], }, }, }); @@ -80,8 +89,13 @@ describe('CodexACPAgent - initialize', () => { clientCapabilities: {_meta: {jetbrains: {air: {version: 1, capabilities: []}}}}, }); expect(result._meta).toEqual({ + runtime: runtimeCapability(), + archive: archiveCapability(), + fileRevert: fileRevertCapability(), + discovery: sessionDiscoveryCapability(), steering: { supported: true, + idleBehavior: ["promptRequired"], }, jetbrains: { air: { @@ -91,7 +105,7 @@ describe('CodexACPAgent - initialize', () => { controlMethod: "_session/goal", actions: ["set", "pause", "resume", "clear"], }, - capabilities: ["sessionFailure", "diffPatch", "agentFileChangeReport", "customInstructions", "nativeSubagentSessions", "asyncTasks", "recommendedValue", "rawInputRendering", "planContentDelta", "codexHooks"], + capabilities: ["sessionFailure", "diffPatch", "agentFileChangeReport", "customInstructions", "nativeSubagentSessions", "asyncTasks", "recommendedValue", "rawInputRendering", "planContentDelta", "codexHooks", "sessionRewind"], }, }, }); diff --git a/src/__tests__/CodexACPAgent/providers.test.ts b/src/__tests__/CodexACPAgent/providers.test.ts index c2f793037..d7e85d877 100644 --- a/src/__tests__/CodexACPAgent/providers.test.ts +++ b/src/__tests__/CodexACPAgent/providers.test.ts @@ -2,6 +2,15 @@ import {describe, expect, it, vi} from "vitest"; import * as acp from "@agentclientprotocol/sdk"; import {createCodexMockTestFixture, createTestSessionState} from "../acp-test-utils"; import {CodexAcpClient, CUSTOM_GATEWAY_PROVIDER_ID, OPENAI_PROVIDER_ID} from "../../CodexAcpClient"; +import {SESSION_REWIND_METHOD} from "../../SessionRewind"; + +function deferred(): {promise: Promise, resolve: (value: T) => void} { + let resolve!: (value: T) => void; + const promise = new Promise(resolvePromise => { + resolve = resolvePromise; + }); + return {promise, resolve}; +} async function expectInvalidParams(fn: () => unknown): Promise { const caught = await Promise.resolve().then(fn).catch((err: unknown) => err); @@ -304,6 +313,61 @@ describe("Configurable LLM providers (providers/*)", () => { }); }); + it("waits for an in-flight provider update before rewinding", async () => { + const replacement = createCodexMockTestFixture().getCodexAcpClient(); + const resumeStarted = deferred(); + const resume = deferred(); + vi.spyOn(replacement, "initialize").mockResolvedValue(); + vi.spyOn(replacement, "resumeSession").mockImplementation(async () => { + resumeStarted.resolve(); + return await resume.promise; + }); + const replacementRewind = vi.spyOn(replacement, "rewindSession").mockResolvedValue({rewound: true}); + const fixture = createCodexMockTestFixture(vi.fn().mockResolvedValue(replacement)); + const agent = fixture.getCodexAcpAgent(); + await agent.initialize({protocolVersion: acp.PROTOCOL_VERSION}); + const sessions = (agent as unknown as {sessions: Map>}).sessions; + sessions.set("thread-1", createTestSessionState({sessionId: "thread-1", cwd: "/workspace"})); + + const providerUpdate = agent.setProvider({ + providerId: OPENAI_PROVIDER_ID, + apiType: "openai", + baseUrl: "https://gateway.example/v1", + }); + await resumeStarted.promise; + const rewind = agent.extMethod(SESSION_REWIND_METHOD, { + sessionId: "thread-1", + beforeMessage: { + messageId: "user-1", + messageFingerprint: `sha256:${"0".repeat(64)}`, + messageOccurrence: 1, + }, + }); + + await Promise.resolve(); + expect(replacementRewind).not.toHaveBeenCalled(); + + resume.resolve({} as never); + await providerUpdate; + await expect(rewind).resolves.toEqual({rewound: true}); + expect(replacementRewind).toHaveBeenCalledOnce(); + }); + + it("rejects rewind for a thread that is not a loaded ACP session", async () => { + const fixture = createCodexMockTestFixture(); + const rewind = vi.spyOn(fixture.getCodexAcpClient(), "rewindSession"); + + await expect(fixture.getCodexAcpAgent().extMethod(SESSION_REWIND_METHOD, { + sessionId: "persisted-but-not-loaded", + beforeMessage: { + messageId: "user-1", + messageFingerprint: `sha256:${"0".repeat(64)}`, + messageOccurrence: 1, + }, + })).rejects.toThrow("Unknown session: persisted-but-not-loaded"); + expect(rewind).not.toHaveBeenCalled(); + }); + it("shares state with the legacy gateway auth method", async () => { const fixture = createCodexMockTestFixture(); const codexAcpClient = fixture.getCodexAcpClient(); diff --git a/src/__tests__/CodexACPAgent/session-queue-client-generation.test.ts b/src/__tests__/CodexACPAgent/session-queue-client-generation.test.ts new file mode 100644 index 000000000..2b6b7c316 --- /dev/null +++ b/src/__tests__/CodexACPAgent/session-queue-client-generation.test.ts @@ -0,0 +1,157 @@ +import {describe, expect, it, vi} from "vitest"; +import {createCodexMockTestFixture} from "../acp-test-utils"; +import {CodexSubagentSubscriptions, type Subscription} from "../../subagents/CodexSubagentSubscriptions"; +import type {ApprovalHandler, ElicitationHandler, CodexAppServerClient} from "../../CodexAppServerClient"; +import type {ServerNotification} from "../../app-server"; +import type {Turn} from "../../app-server/v2"; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise(r => { resolve = r; }); + return {promise, resolve}; +} +const turn = (id: string, status: Turn["status"] = "inProgress"): Turn => ({ + id, status, items: [], itemsView: "notLoaded", error: null, + startedAt: null, completedAt: null, durationMs: null, +}); +function owner(): Subscription { + return { + rootSessionId: "root", supportsSubagents: true, + dispatch: vi.fn(), enqueueInteraction: vi.fn(), + approvalHandler: { + handleCommandExecution: vi.fn().mockResolvedValue({decision: "accept"}), + handleFileChange: vi.fn().mockResolvedValue({decision: "accept"}), + handlePermissionsRequest: vi.fn().mockResolvedValue({permissions: {network: {enabled: true}}, scope: "turn", strictAutoReview: false}), + }, + elicitationHandler: { + handleElicitation: vi.fn().mockResolvedValue({action: "accept", content: null, _meta: null}), + handleUserInput: vi.fn().mockResolvedValue({answers: {answer: {answers: ["yes"]}}}), + }, + waitForRootNotifications: vi.fn().mockResolvedValue(undefined), + waitForChildSession: vi.fn().mockImplementation(async id => id), + }; +} +function setup() { + const events = new Map void>(); + const approvals = new Map(); + const elicitations = new Map(); + const subscriptions = new CodexSubagentSubscriptions({ + onServerNotification: (id: string, callback: (event: ServerNotification) => void) => events.set(id, callback), + onApprovalRequest: (id: string, handler: ApprovalHandler) => approvals.set(id, handler), + onElicitationRequest: (id: string, handler: ElicitationHandler) => elicitations.set(id, handler), + clearThreadHandlers: (id: string) => { events.delete(id); approvals.delete(id); elicitations.delete(id); }, + } as unknown as CodexAppServerClient); + const subscription = owner(); + subscription.captureChild = () => subscription; + subscriptions.subscribe(subscription); + const emit = (threadId: string, method: string, params: object) => + events.get(threadId)?.({method, params: {threadId, ...params}} as ServerNotification); + const spawn = (tool = "spawnAgent") => emit("root", "item/completed", { + turnId: "parent", item: {type: "collabAgentToolCall", tool, receiverThreadIds: ["child"]}, + }); + const reuse = () => { + emit("child", "turn/completed", {turn: turn("old", "completed")}); + spawn("sendInput"); + emit("child", "turn/started", {turn: turn("new")}); + }; + spawn(); + emit("child", "turn/started", {turn: turn("old")}); + return {events, approvals, elicitations, subscriptions, subscription, emit, reuse}; +} +const interactions = [ + ["handleCommandExecution", {decision: "cancel"}], + ["handleFileChange", {decision: "cancel"}], + ["handlePermissionsRequest", {permissions: {}, scope: "turn", strictAutoReview: false}], + ["handleElicitation", {action: "cancel", content: null, _meta: null}], + ["handleUserInput", {answers: {}}], +] as const; +function request(h: ReturnType, method: typeof interactions[number][0], turnId: string | null) { + const handlers = {...h.approvals.get("child")!, ...h.elicitations.get("child")!}; + // This unit boundary only consumes thread/turn IDs; downstream handlers are mocks. + return (handlers[method] as (params: {threadId: string; turnId: string | null}) => Promise)({threadId: "child", turnId}); +} + +describe("queue subscription generation boundaries", () => { + it.each(interactions)("rejects stale child turn IDs for %s and preserves the new turn", async (method, rejected) => { + const h = setup(); + h.reuse(); + expect(await request(h, method, "old")).toEqual(rejected); + expect(await request(h, method, "new")).not.toEqual(rejected); + }); + + it.each(interactions)("rejects %s captured before a child generation replacement", async (method, rejected) => { + const h = setup(); + const gate = deferred(); + vi.mocked(h.subscription.waitForRootNotifications).mockReturnValueOnce(gate.promise); + const pending = request(h, method, "old"); + h.reuse(); + gate.resolve(); + expect(await pending).toEqual(rejected); + }); + + it("rechecks generation after waiting for child materialization", async () => { + const h = setup(); + const entered = deferred(); + const gate = deferred(); + vi.mocked(h.subscription.waitForChildSession).mockImplementationOnce(() => { entered.resolve(); return gate.promise; }); + const pending = request(h, "handleCommandExecution", "old"); + await entered.promise; + h.reuse(); + gate.resolve("child"); + expect(await pending).toEqual({decision: "cancel"}); + }); + + it("does not let old starts or completions reopen or end a reused child", async () => { + const h = setup(); + const oldCallback = h.events.get("child")!; + h.reuse(); + h.emit("child", "turn/started", {turn: turn("old")}); + h.emit("child", "turn/completed", {turn: turn("old", "completed")}); + oldCallback({method: "turn/completed", params: {threadId: "child", turn: turn("old", "completed")}}); + expect(await request(h, "handleCommandExecution", "new")).toEqual({decision: "accept"}); + h.emit("child", "turn/completed", {turn: turn("new", "completed")}); + h.emit("child", "turn/started", {turn: turn("new")}); + expect(await request(h, "handleCommandExecution", "new")).toEqual({decision: "cancel"}); + }); + + it("rejects uncorrelated elicitation when its child turn changes during the barrier", async () => { + const h = setup(); + const gate = deferred(); + vi.mocked(h.subscription.waitForRootNotifications).mockReturnValueOnce(gate.promise); + const pending = request(h, "handleElicitation", null); + h.reuse(); + gate.resolve(); + expect(await pending).toMatchObject({action: "cancel"}); + }); + + it("observes root lifecycle synchronously during a prompt and fences retired callbacks", async () => { + const f = createCodexMockTestFixture(); + const client = f.getCodexAcpClient(); + const native = f.getCodexAppServerClient(); + const registrations = vi.spyOn(native, "onServerNotification"); + const old = owner(); + const gate = deferred(); + const observed = vi.fn(); + vi.mocked(old.dispatch).mockImplementation(() => gate.promise); + const attach = (subscription: Subscription, watch: typeof observed) => client.subscribeToQueueEvents( + "root", subscription.dispatch, subscription.approvalHandler, subscription.elicitationHandler, + true, subscription.enqueueInteraction, subscription.waitForChildSession, true, undefined, watch, + ); + await attach(old, observed); + await client.subscribeToSessionEvents("root", old.dispatch, old.approvalHandler, old.elicitationHandler, + true, old.enqueueInteraction, old.waitForChildSession); + const callback = registrations.mock.calls[0]![1]; + const started: ServerNotification = {method: "turn/started", params: {threadId: "root", turn: turn("first")}}; + callback(started); + expect(observed).toHaveBeenCalledExactlyOnceWith(started); + const fresh = owner(); + const freshObserved = vi.fn(); + await attach(fresh, freshObserved); + callback(started); + expect(observed).toHaveBeenCalledTimes(1); + expect(freshObserved).not.toHaveBeenCalled(); + gate.resolve(); + await client.waitForSessionNotifications("root"); + expect(fresh.dispatch).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/CodexACPAgent/session-queue-lifecycle.test.ts b/src/__tests__/CodexACPAgent/session-queue-lifecycle.test.ts new file mode 100644 index 000000000..12abef8a9 --- /dev/null +++ b/src/__tests__/CodexACPAgent/session-queue-lifecycle.test.ts @@ -0,0 +1,166 @@ +import {describe, expect, it, vi} from "vitest"; +import {createCodexMockTestFixture, createTestSessionState} from "../acp-test-utils"; +import {SESSION_QUEUE_ACTIONS, type SessionQueueNative} from "../../SessionQueue"; +import type {SessionState} from "../../CodexAcpServer"; +import type {Turn} from "../../app-server/v2"; +import {SESSION_REWIND_METHOD} from "../../SessionRewind"; + +const sessionId = "session-id"; +const turn = (id: string, status: Turn["status"] = "inProgress"): Turn => ({ + id, status, items: [], itemsView: "notLoaded", error: null, startedAt: null, completedAt: null, durationMs: null, +}); +function deferred() { let resolve!: (value: T) => void; const promise = new Promise(r => {resolve = r;}); return {promise, resolve}; } +async function setup() { + const f = createCodexMockTestFixture(); + const agent = f.getCodexAcpAgent(), client = f.getCodexAcpClient(), native = f.getCodexAppServerClient(); + client.queueSupport = {nativeVersion: "0.160.1", actions: SESSION_QUEUE_ACTIONS}; + const state = createTestSessionState(); + (agent as unknown as {sessions: Map}).sessions.set(sessionId, state); + const queue = { + list: vi.fn().mockResolvedValue({data: [], nextCursor: null}), + add: vi.fn().mockResolvedValue({queuedSubmission: {id: "q", clientUserMessageId: "client", input: []}}), + update: vi.fn().mockResolvedValue({queuedSubmission: {id: "q", clientUserMessageId: "client", input: []}}), + delete: vi.fn().mockResolvedValue({deleted: true}), reorder: vi.fn().mockResolvedValue({}), + start: vi.fn().mockResolvedValue({turn: turn("queued")}), + } satisfies SessionQueueNative; + vi.spyOn(native, "queueNative").mockReturnValue(queue); + vi.spyOn(native, "threadRead").mockResolvedValue({thread: {status: {type: "idle"}}} as never); + const request = (action: string, extra = {}) => agent.manageSessionQueue({sessionId, action, ...extra}); + await request("list"); + const emit = async (method: string, params: object) => { + f.sendServerNotification({method, params: {threadId: sessionId, ...params}}); + await client.waitForSessionNotifications(sessionId); + }; + return {f, agent, client, native, state, queue, request, emit}; +} + +describe("native queue ownership", () => { + it("excludes active autonomous turns from rewind, file reversal, archive and provider controls", async () => { + const {agent,client,emit}=await setup(); + const rewind=vi.spyOn(client,"rewindSession").mockResolvedValue({rewound:true}); + const reload=vi.spyOn(client,"reloadMcpServers").mockResolvedValue(); + await emit("turn/started",{turn:turn("autonomous")}); + for(const operation of [ + ()=>agent.extMethod(SESSION_REWIND_METHOD,{sessionId,beforeMessage:{messageId:"user",messageFingerprint:"sha256:"+"0".repeat(64),messageOccurrence:1}}), + ()=>agent.revertFiles({sessionId,toolCallId:"file",dryRun:true}), + ()=>agent.archiveSession(sessionId,true), + ()=>agent.controlSessionRuntime({sessionId,action:"reconnectMcp"}), + ]) await expect(operation()).rejects.toMatchObject({data:expect.stringContaining("Queued work is active")}); + expect(rewind).not.toHaveBeenCalled();expect(reload).not.toHaveBeenCalled(); + await emit("turn/completed",{turn:turn("autonomous","completed")}); + // Root completion is published before the retained child-owner cleanup settles. + await vi.waitFor(()=>expect((agent as unknown as {queueOwners:Map}).queueOwners.get(sessionId)?.active()).toBe(false)); + await expect(agent.controlSessionRuntime({sessionId,action:"reconnectMcp"})).resolves.toMatchObject({status:"ok"}); + }); + + it("requires persistent pending entries to be cleared before rewind or provider-wide runtime writes",async()=>{ + const {agent,client,queue}=await setup(); + const rewind=vi.spyOn(client,"rewindSession").mockResolvedValue({rewound:true}); + queue.list.mockResolvedValue({data:[{id:"pending"}],nextCursor:null} as never); + const request={sessionId,beforeMessage:{messageId:"user",messageFingerprint:"sha256:"+"0".repeat(64),messageOccurrence:1}}; + await expect(agent.extMethod(SESSION_REWIND_METHOD,request)).rejects.toMatchObject({data:expect.stringContaining("Pending native queue")}); + await expect(agent.controlSessionRuntime({sessionId,action:"reconnectMcp"})).rejects.toMatchObject({data:expect.stringContaining("Pending native queue")}); + expect(rewind).not.toHaveBeenCalled(); + queue.list.mockResolvedValue({data:[],nextCursor:null}); + await expect(agent.extMethod(SESSION_REWIND_METHOD,request)).resolves.toEqual({rewound:true}); + }); + + it("blocks queue writes after an uncertain rewind instead of letting them generate against unknown history",async()=>{ + const {agent,client,queue,request}=await setup(); + vi.spyOn(client,"rewindSession").mockImplementation(async(_params,hooks)=>{hooks?.mutationStarted?.();throw new Error("lost rewind acknowledgement");}); + await expect(agent.extMethod(SESSION_REWIND_METHOD,{sessionId,beforeMessage:{messageId:"user",messageFingerprint:"sha256:"+"0".repeat(64),messageOccurrence:1}})).rejects.toThrow("lost rewind"); + await expect(request("add",{input:[{type:"text",text:"new"}],clientUserMessageId:"next"})).rejects.toMatchObject({data:expect.stringContaining("rewind outcome is uncertain")}); + expect(queue.add).not.toHaveBeenCalled(); + }); + + it("keeps session-scoped updates observable before and after autonomous turns", async () => { + const {state, emit} = await setup(); + await emit("thread/name/updated", {threadName: "before queue"}); + expect(state.sessionTitle).toBe("before queue"); + await emit("turn/started", {turn: turn("queued")}); + await emit("turn/completed", {turn: turn("queued", "completed")}); + await emit("thread/name/updated", {threadName: "after queue"}); + expect(state.sessionTitle).toBe("after queue"); + }); + it("attaches before idle add, publishes output and terminal events after the RPC returned", async () => { + const {f, state, queue, request, emit, agent} = await setup(); + queue.add.mockImplementationOnce(async () => { + await emit("turn/started", {turn: turn("queued")}); + return {queuedSubmission: {id: "q", clientUserMessageId: "client", input: []}}; + }); + await request("add", {input: [{type: "text", text: "hello"}], clientUserMessageId: "client"}); + expect(state.currentTurnId).toBe("queued"); + await expect(agent.prompt({sessionId, prompt: []})).rejects.toMatchObject({data: expect.stringContaining("queued turn")}); + await emit("item/agentMessage/delta", {turnId: "queued", itemId: "answer", delta: "queue answer"}); + await emit("turn/completed", {turn: turn("queued", "completed")}); + expect(state.currentTurnId).toBeNull(); + const events = f.getAcpConnectionEvents([]); + expect(JSON.stringify(events)).toContain("queue answer"); + expect(events.filter(e => e.args[0] === "_session/queue/turn").map(e => (e.args[1] as any).turn.status)).toEqual(["inProgress", "completed"]); + }); + + it("hands automatic successors off without old prompt cleanup clearing their turn", async () => { + const {f, agent, native, client, state, emit, request} = await setup(); + const completed = deferred(); + vi.spyOn(native, "turnStart").mockImplementation(async () => { + await emit("turn/started", {turn: turn("normal")}); return {turn: turn("normal")}; + }); + vi.spyOn(native, "awaitTurnCompleted").mockReturnValue(completed.promise); + const prompt = agent.prompt({sessionId, prompt: [{type: "text", text: "normal"}]}); + await vi.waitFor(() => expect(state.currentTurnId).toBe("normal")); + await request("add", {input: [], clientUserMessageId: "client"}); + await emit("turn/completed", {turn: turn("normal", "completed")}); + await emit("turn/started", {turn: turn("successor")}); + completed.resolve({threadId: sessionId, turn: turn("normal", "completed")}); + await prompt; + expect(state.currentTurnId).toBe("successor"); + await emit("item/agentMessage/delta", {turnId: "successor", itemId: "answer", delta: "still alive"}); + expect(JSON.stringify(f.getAcpConnectionEvents([]))).toContain("still alive"); + const interrupt = vi.spyOn(client, "turnInterrupt").mockResolvedValue(); + await agent.cancel({sessionId}); + expect(interrupt).toHaveBeenCalledWith({threadId: sessionId, turnId: "successor"}); + await emit("turn/completed", {turn: turn("successor", "interrupted")}); + }); + + it("routes queue approval through a fresh non-aborted context", async () => { + const {f, emit} = await setup(); + await emit("turn/started", {turn: turn("q1")}); + await emit("turn/completed", {turn: turn("q1", "interrupted")}); + await emit("turn/started", {turn: turn("q2")}); + f.setPermissionResponse({outcome: {outcome: "selected", optionId: "allow_once"}}); + const response = await f.sendServerRequest("item/commandExecution/requestApproval", { + threadId: sessionId, turnId: "q2", itemId: "cmd", approvalId: null, + command: "echo queue", cwd: "/test/cwd", reason: null, availableDecisions: ["accept", "decline"], + }); + expect(response).toEqual({decision: "accept"}); + await emit("turn/completed", {turn: turn("q2", "completed")}); + }); + + it("serializes edits and does not retry uncertain mutations", async () => { + const {agent, request, queue} = await setup(); + const gate = deferred(); queue.update.mockReturnValueOnce(gate.promise); + const updating = request("update", {queuedSubmissionId: "q", input: []}); + await vi.waitFor(() => expect(queue.update).toHaveBeenCalledOnce()); + await expect(request("reorder", {queuedSubmissionIds: ["q"]})).rejects.toThrow(); + await expect(agent.closeSession({sessionId})).rejects.toThrow(); + gate.resolve({queuedSubmission: {id: "q", clientUserMessageId: "client", input: []}}); await updating; + queue.add.mockRejectedValueOnce(new Error("connection lost after commit")); + await expect(request("add", {input: [], clientUserMessageId: "client"})).rejects.toThrow("connection lost"); + await expect(request("add", {input: [], clientUserMessageId: "client"})).rejects.toThrow(); + expect(queue.add).toHaveBeenCalledOnce(); + }); + + it("manages persistent entries while unloaded without resuming or executing them", async () => { + const {agent, client, native, request, queue} = await setup(); + await agent.closeSession({sessionId}); + vi.mocked(native.threadRead).mockResolvedValue({thread: {status: {type: "notLoaded"}}} as never); + const resume = vi.spyOn(client, "resumeSession"); + await request("add", {input: [], clientUserMessageId: "client"}); + await request("update", {queuedSubmissionId: "q", input: []}); + await request("reorder", {queuedSubmissionIds: ["q"]}); + await request("delete", {queuedSubmissionId: "q"}); + expect(queue.add).toHaveBeenCalledOnce(); expect(resume).not.toHaveBeenCalled(); + await expect(request("start")).rejects.toThrow(); + expect(queue.start).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/CodexACPAgent/session-queue-maintainer.test.ts b/src/__tests__/CodexACPAgent/session-queue-maintainer.test.ts new file mode 100644 index 000000000..c2eb1167f --- /dev/null +++ b/src/__tests__/CodexACPAgent/session-queue-maintainer.test.ts @@ -0,0 +1,240 @@ +import {describe, expect, it, vi} from "vitest"; +import {createCodexMockTestFixture, createTestSessionState} from "../acp-test-utils"; +import {SESSION_QUEUE_ACTIONS, type SessionQueueNative} from "../../SessionQueue"; +import type {CodexAcpServer, SessionState} from "../../CodexAcpServer"; +import type {AcpClientConnection} from "../../ACPSessionConnection"; +import type {ThreadItem, ThreadReadResponse, Turn} from "../../app-server/v2"; + +const sessionId = "session-id"; +const turn = (id: string, status: Turn["status"] = "inProgress"): Turn => ({ + id, status, items: [], itemsView: "notLoaded", error: null, + startedAt: null, completedAt: null, durationMs: null, +}); +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise(r => { resolve = r; }); + return {promise, resolve}; +} +const checkpoint = () => new Promise(resolve => setImmediate(resolve)); + +async function setup(attach = true) { + const f = createCodexMockTestFixture(); + const agent = f.getCodexAcpAgent(); + const client = f.getCodexAcpClient(); + const native = f.getCodexAppServerClient(); + const connection = f.getAcpConnection(); + // Model the real ACP cancellation boundary, absent from the shared mock. + (agent as unknown as {connection: AcpClientConnection}).connection = new Proxy(connection, { + get(target, property) { + if (property !== "request") return Reflect.get(target, property); + return (...args: Parameters) => { + if (args[2]?.cancellationSignal?.aborted) return Promise.reject(new Error("Permission context aborted")); + return Reflect.get(target, property)(...args); + }; + }, + }); + client.queueSupport = {nativeVersion: "0.160.1", actions: SESSION_QUEUE_ACTIONS}; + const state = createTestSessionState(); + const boundaries = agent as unknown as { + sessions: Map; + installSessionState(state: SessionState): void; + }; + boundaries.sessions.set(sessionId, state); + const submission = {id: "q", clientUserMessageId: "client", input: []}; + const queue = { + list: vi.fn().mockResolvedValue({data: [], nextCursor: null}), + add: vi.fn().mockResolvedValue({queuedSubmission: submission}), + update: vi.fn().mockResolvedValue({queuedSubmission: submission}), + delete: vi.fn().mockResolvedValue({deleted: true}), + reorder: vi.fn().mockResolvedValue({}), + start: vi.fn().mockResolvedValue({turn: turn("queued")}), + } satisfies SessionQueueNative; + vi.spyOn(native, "queueNative").mockReturnValue(queue); + vi.spyOn(native, "threadRead").mockResolvedValue({thread: {id: sessionId, status: {type: "idle"}}} as ThreadReadResponse); + const send = (method: string, params: object = {}, threadId = sessionId) => + f.sendServerNotification({method, params: {threadId, ...params}}); + const emit = async (method: string, params: object = {}, threadId = sessionId) => { + send(method, params, threadId); + await client.waitForSessionNotifications(sessionId); + }; + if (attach) await agent.manageSessionQueue({sessionId, action: "list"}); + f.setPermissionResponse({outcome: {outcome: "selected", optionId: "allow_once"}}); + const spawn = async (parent: string, parentTurn: string, child: string) => { + await emit("item/completed", {turnId: parentTurn, completedAtMs: 0, item: { + type: "collabAgentToolCall", id: `spawn-${child}`, tool: "spawnAgent", status: "completed", + senderThreadId: parent, receiverThreadIds: [child], prompt: "child task", + model: null, reasoningEffort: null, agentsStates: {[child]: {status: "running", message: null}}, + } satisfies ThreadItem}, parent); + await emit("turn/started", {turn: turn(`turn-${child}`)}, child); + }; + const approval = (child: string) => f.sendServerRequest("item/commandExecution/requestApproval", { + threadId: child, turnId: `turn-${child}`, itemId: `cmd-${child}`, approvalId: null, + kind: "command", startedAtMs: 0, environmentId: null, + command: "echo test", cwd: "/test/cwd", availableDecisions: ["accept", "decline"], + }); + return {f, agent, client, native, state, boundaries, queue, send, emit, spawn, approval}; +} + +describe("independent maintainer queue regressions", () => { + it("retries cancellation while an autonomous turn is not registered as interruptible yet", async () => { + const h = await setup(); + await h.emit("turn/started", {turn: turn("queued")}); + const interrupt = vi.spyOn(h.client, "turnInterrupt") + .mockRejectedValueOnce(new Error("no active turn to interrupt")) + .mockResolvedValue(); + try { + await h.agent.cancel({sessionId}); + expect(interrupt).toHaveBeenCalledTimes(2); + } finally { + await h.emit("turn/completed", {turn: turn("queued", "interrupted")}); + } + }); + + it("preserves cancellation while the first queue subscription is being installed", async () => { + const h = await setup(false); + const entered = deferred(); + const release = deferred(); + const subscribe = h.client.subscribeToQueueEvents.bind(h.client); + vi.spyOn(h.client, "subscribeToQueueEvents").mockImplementationOnce(async (...args) => { + await subscribe(...args); + entered.resolve(); + await release.promise; + }); + const interrupt = vi.spyOn(h.client, "turnInterrupt").mockResolvedValue(); + const add = h.agent.manageSessionQueue({sessionId, action: "add", input: [], clientUserMessageId: "client"}); + await entered.promise; + await h.agent.cancel({sessionId}); + release.resolve(); + await add; + try { + await h.emit("turn/started", {turn: turn("queued")}); + expect(interrupt).toHaveBeenCalledWith({threadId: sessionId, turnId: "queued"}); + } finally { + await h.emit("turn/completed", {turn: turn("queued", "interrupted")}); + } + }); + + it("does not replay buffered notifications from the old subscription into a replacement owner", async () => { + const h = await setup(); + const entered = deferred(); + const release = deferred(); + const connection = h.f.getAcpConnection(); + const notify = connection.notify.bind(connection); + let block = true; + const original = (h.agent as unknown as {connection: AcpClientConnection}).connection; + (h.agent as unknown as {connection: AcpClientConnection}).connection = new Proxy(original, { + get(target, property) { + if (property !== "notify") return Reflect.get(target, property); + return async (...args: Parameters) => { + if (block) { block = false; entered.resolve(); await release.promise; } + return notify(...args); + }; + }, + }); + h.send("thread/queue/changed"); + await entered.promise; + h.send("turn/started", {turn: turn("old-subscription-turn")}); + const fresh = createTestSessionState(); + const metadata: Awaited> = [ + sessionId, {currentModelId: fresh.currentModelId, availableModels: []}, fresh.agentMode.toSessionModeState(false), + ]; + vi.spyOn(h.agent, "getOrCreateSession").mockImplementationOnce(async () => { + h.boundaries.installSessionState(fresh); + return metadata; + }); + try { + await h.agent.closeSession({sessionId}); + await h.agent.resumeSession({sessionId, cwd: "/test/cwd", mcpServers: []}); + } finally { + release.resolve(); + } + try { + await h.client.waitForSessionNotifications(sessionId); + expect(fresh.currentTurnId).toBeNull(); + } finally { + await h.emit("turn/completed", {turn: turn("old-subscription-turn", "interrupted")}); + } + }); + + it("keeps hidden grandchildren alive when they are discovered after root completion", async () => { + const h = await setup(); + await h.emit("turn/started", {turn: turn("queued")}); + await h.spawn(sessionId, "queued", "child"); + await h.emit("turn/completed", {turn: turn("queued", "completed")}); + await checkpoint(); + await h.spawn("child", "turn-child", "grandchild"); + expect(await h.approval("grandchild")).toEqual({decision: "accept"}); + await h.emit("turn/completed", {turn: turn("turn-child", "completed")}, "child"); + await checkpoint(); + try { + expect(await h.approval("grandchild")).toEqual({decision: "accept"}); + } finally { + await h.emit("turn/completed", {turn: turn("turn-grandchild", "completed")}, "grandchild"); + } + }); + + it("interrupts early autonomous execution when a cold load fails during history replay", async () => { + const h = await setup(false); + h.boundaries.sessions.clear(); + type HistoryBoundary = { + getOrCreateSessionWithHistory: (...args: unknown[]) => Promise; + streamThreadHistory: (...args: unknown[]) => Promise; + }; + const history = h.agent as unknown as HistoryBoundary; + const interrupt = vi.spyOn(h.client, "turnInterrupt").mockResolvedValue(); + vi.spyOn(history, "getOrCreateSessionWithHistory").mockImplementationOnce(async () => { + h.send("turn/started", {turn: turn("early")}); + h.boundaries.installSessionState(h.state); + return {sessionId, modelState: {currentModelId: h.state.currentModelId, availableModels: []}, + modeState: h.state.agentMode.toSessionModeState(false), thread: {id: sessionId}, + history: (async function* () {})()}; + }); + vi.spyOn(history, "streamThreadHistory").mockRejectedValueOnce(new Error("history read failed")); + await expect(h.agent.loadSession({sessionId, cwd: "/test/cwd", mcpServers: []})).rejects.toThrow("history read failed"); + await h.client.waitForSessionNotifications(sessionId); + expect(interrupt).toHaveBeenCalledWith({threadId: sessionId, turnId: "early"}); + }); + + it("rejects a late old-turn approval after a child thread is reused", async () => { + const h = await setup(); + await h.emit("turn/started", {turn: turn("first")}); + await h.spawn(sessionId, "first", "reused"); + await h.emit("turn/completed", {turn: turn("turn-reused", "completed")}, "reused"); + await h.emit("turn/completed", {turn: turn("first", "completed")}); + await checkpoint(); + await h.emit("turn/started", {turn: turn("second")}); + await h.emit("item/completed", {turnId: "second", completedAtMs: 0, item: { + type: "collabAgentToolCall", id: "reuse", tool: "sendInput", status: "completed", + senderThreadId: sessionId, receiverThreadIds: ["reused"], prompt: "follow-up", + model: null, reasoningEffort: null, agentsStates: {reused: {status: "running", message: null}}, + } satisfies ThreadItem}); + await h.emit("turn/started", {turn: turn("turn-reused-new")}, "reused"); + try { + // approval() deliberately sends the previous turn-reused id. + expect(await h.approval("reused")).toEqual({decision: "cancel"}); + } finally { + await h.emit("turn/completed", {turn: turn("turn-reused-new", "completed")}, "reused"); + await h.emit("turn/completed", {turn: turn("second", "completed")}); + } + }); + + it("does not dispatch an add cancelled while unloaded-thread preflight is pending", async () => { + const h = await setup(false); + h.boundaries.sessions.clear(); + const entered = deferred(); + const release = deferred(); + vi.mocked(h.native.threadRead).mockImplementationOnce(async () => { + entered.resolve(); + await release.promise; + return {thread: {id: sessionId, status: {type: "notLoaded"}}} as ThreadReadResponse; + }); + const abort = new AbortController(); + const add = h.agent.manageSessionQueue({sessionId, action: "add", input: [], clientUserMessageId: "client"}, abort.signal); + const result = add.then(value => ({value}), error => ({error})); + await entered.promise; + abort.abort(); + release.resolve(); + expect(await result).toHaveProperty("error"); + expect(h.queue.add).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/CodexACPAgent/session-queue-resume.test.ts b/src/__tests__/CodexACPAgent/session-queue-resume.test.ts new file mode 100644 index 000000000..d294cc9ee --- /dev/null +++ b/src/__tests__/CodexACPAgent/session-queue-resume.test.ts @@ -0,0 +1,241 @@ +import {describe, expect, it, vi} from "vitest"; +import type {LoadSessionRequest} from "@agentclientprotocol/sdk"; +import {createCodexMockTestFixture, createTestSessionState} from "../acp-test-utils"; +import type {CodexAcpServer, SessionState} from "../../CodexAcpServer"; +import {ACPSessionConnection} from "../../ACPSessionConnection"; +import {SESSION_QUEUE_ACTIONS} from "../../SessionQueue"; +import {TitleGenerator} from "../../TitleGenerator"; +import type {Thread, ThreadItem, Turn} from "../../app-server/v2"; + +const sessionId = "session-id"; +const params = {sessionId, cwd: "/test/cwd", mcpServers: []}; +const turn = (id: string, status: Turn["status"] = "inProgress"): Turn => ({ + id, status, items: [], itemsView: "notLoaded", error: null, + startedAt: null, completedAt: null, durationMs: null, +}); + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise(r => { resolve = r; }); + return {promise, resolve}; +} + +type Metadata = Awaited>; +type HistoryMetadata = { + sessionId: string; + modelState: Metadata[1]; + modeState: Metadata[2]; + thread: Thread; + history: AsyncIterable; +}; +// Only metadata/history boundaries are replaced. Installation and queue routing stay real. +type OpenBoundaries = { + sessions: Map; + installSessionState(state: SessionState): void; + getOrCreateSessionWithHistory(request: LoadSessionRequest): Promise; + streamThreadHistory(sessionId: string, thread: Thread, history: AsyncIterable): Promise; +}; + +function setup() { + const f = createCodexMockTestFixture(); + const agent = f.getCodexAcpAgent(); + const client = f.getCodexAcpClient(); + const native = f.getCodexAppServerClient(); + client.queueSupport = {nativeVersion: "0.160.1", actions: SESSION_QUEUE_ACTIONS}; + const boundaries = agent as unknown as OpenBoundaries; + const state = createTestSessionState(); + const metadata: Metadata = [sessionId, + {currentModelId: state.currentModelId, availableModels: []}, + state.agentMode.toSessionModeState(false), + ]; + const historyMetadata: HistoryMetadata = { + sessionId, modelState: metadata[1], modeState: metadata[2], + // The history boundary below is stubbed; no production code consumes this thread. + thread: {id: sessionId} as Thread, + history: (async function* () {})(), + }; + const send = (method: string, values: object = {}) => + f.sendServerNotification({method, params: {threadId: sessionId, ...values}}); + const emit = async (method: string, values: object = {}) => { + send(method, values); + await client.waitForSessionNotifications(sessionId); + }; + const events = () => f.getAcpConnectionEvents([]); + const text = () => events().filter(event => event.method === "sessionUpdate" + && event.args[0].update.sessionUpdate === "agent_message_chunk") + .map(event => event.args[0].update.content.text); + const approval = (id: string) => f.sendServerRequest("item/commandExecution/requestApproval", { + threadId: sessionId, turnId: id, itemId: `cmd-${id}`, approvalId: null, + kind: "command", startedAtMs: 0, environmentId: null, + command: `echo ${id}`, cwd: params.cwd, availableDecisions: ["accept", "decline"], + }); + f.setPermissionResponse({outcome: {outcome: "selected", optionId: "allow_once"}}); + const early = (id: string) => { + send("turn/started", {turn: turn(id)}); + send("item/agentMessage/delta", {turnId: id, itemId: `answer-${id}`, delta: `live ${id}`}); + let settled = false; + const response = approval(id).then(value => { settled = true; return value; }); + return {response, settled: () => settled}; + }; + const stubResume = (install: () => Promise) => vi.spyOn(agent, "getOrCreateSession") + .mockImplementation(async () => { await install(); return metadata; }); + const stubLoad = (install: () => Promise) => vi.spyOn(boundaries, "getOrCreateSessionWithHistory") + .mockImplementation(async () => { await install(); return historyMetadata; }); + return {f, agent, client, native, boundaries, state, send, emit, events, text, approval, early, stubResume, stubLoad}; +} + +// Advance runnable promise continuations, without wall-clock sleeps or awaiting the blocked queue. +const checkpoint = () => new Promise(resolve => setImmediate(resolve)); + +describe("native queue resume and history buffering", () => { + it("buffers native start, output and approval before resume installs SessionState", async () => { + const h = setup(); + const entered = deferred(); + const install = deferred(); + h.stubResume(async () => { + entered.resolve(); + await install.promise; + h.boundaries.installSessionState(h.state); + }); + const resumed = h.agent.resumeSession(params); + await entered.promise; + const pending = h.early("resume"); + try { + await checkpoint(); + expect(h.boundaries.sessions.has(sessionId)).toBe(false); + expect(pending.settled()).toBe(false); + expect(h.events()).toEqual([]); + } finally { + install.resolve(); + await resumed; + } + try { + await h.client.waitForSessionNotifications(sessionId); + expect(await pending.response).toEqual({decision: "accept"}); + expect(h.state.currentTurnId).toBe("resume"); + expect(h.text()).toEqual(["live resume"]); + expect(h.events().filter(event => event.method === "requestPermission")).toHaveLength(1); + } finally { + await h.emit("turn/completed", {turn: turn("resume", "completed")}); + } + }); + + it.each([false, true])("keeps live events behind history replay and previous title settlement (reload=%s)", async reload => { + const h = setup(); + const entered = deferred(); + const install = deferred(); + const replayEntered = deferred(); + const replay = deferred(); + const titleEntered = deferred(); + const title = deferred(); + if (reload) { + const previous = createTestSessionState(); + previous.titleGen = new TitleGenerator(h.native, sessionId, params.cwd, () => "unknown"); + vi.spyOn(previous.titleGen, "waitForIdle").mockImplementation(async () => { + titleEntered.resolve(); + await title.promise; + }); + // A reload must replace the already-ready owner from the preceding open. + h.boundaries.installSessionState(previous); + await checkpoint(); + } + h.stubLoad(async () => { + entered.resolve(); + await install.promise; + h.boundaries.installSessionState(h.state); + }); + vi.spyOn(h.boundaries, "streamThreadHistory").mockImplementation(async () => { + replayEntered.resolve(); + await replay.promise; + await new ACPSessionConnection(h.f.getAcpConnection(), sessionId).update({ + sessionUpdate: "agent_message_chunk", content: {type: "text", text: "history replay"}, + }); + }); + const loaded = h.agent.loadSession(params); + await entered.promise; + const pending = h.early("load"); + try { + await checkpoint(); + expect(h.boundaries.sessions.get(sessionId)).not.toBe(h.state); + expect(h.events()).toEqual([]); + expect(pending.settled()).toBe(false); + install.resolve(); + await replayEntered.promise; + await checkpoint(); + expect(h.boundaries.sessions.get(sessionId)).toBe(h.state); + expect(h.events()).toEqual([]); + expect(pending.settled()).toBe(false); + replay.resolve(); + if (reload) { + await titleEntered.promise; + await checkpoint(); + expect(h.text()).toEqual(["history replay"]); + expect(pending.settled()).toBe(false); + } + } finally { + install.resolve(); + replay.resolve(); + title.resolve(); + await loaded; + } + try { + await h.client.waitForSessionNotifications(sessionId); + expect(await pending.response).toEqual({decision: "accept"}); + expect(h.text()).toEqual(["history replay", "live load"]); + expect(h.events().filter(event => event.method === "requestPermission")).toHaveLength(1); + } finally { + await h.emit("turn/completed", {turn: turn("load", "completed")}); + } + }); + + it.each(["resume metadata", "load metadata", "load history"] as const)( + "releases buffered waits after failed %s and replaces failed routing on retry", + async stage => { + const h = setup(); + const entered = deferred(); + const fail = deferred(); + const failure = new Error(`failed ${stage}`); + const reject = async () => { entered.resolve(); await fail.promise; throw failure; }; + if (stage === "resume metadata") h.stubResume(reject); + else if (stage === "load metadata") h.stubLoad(reject); + else { + h.stubLoad(async () => { h.boundaries.installSessionState(h.state); }); + vi.spyOn(h.boundaries, "streamThreadHistory").mockImplementation(reject); + } + const opening = stage === "resume metadata" ? h.agent.resumeSession(params) : h.agent.loadSession(params); + // Observe rejection immediately so a cleanup failure cannot become an unhandled rejection. + const result = opening.then(() => null, error => error); + await entered.promise; + const pending = h.early("failed"); + try { + await checkpoint(); + expect(pending.settled()).toBe(false); + expect(h.events()).toEqual([]); + } finally { + fail.resolve(); + } + expect(await result).toBe(failure); + await h.client.waitForSessionNotifications(sessionId); + expect(await pending.response).toEqual({decision: "cancel"}); + expect(h.boundaries.sessions.has(sessionId)).toBe(false); + await h.emit("turn/started", {turn: turn("orphan")}); + await h.emit("item/agentMessage/delta", {turnId: "orphan", itemId: "orphan", delta: "orphan output"}); + expect(await h.approval("orphan")).toEqual({decision: "cancel"}); + expect(h.events()).toEqual([]); + + const retryState = createTestSessionState(); + h.stubResume(async () => { h.boundaries.installSessionState(retryState); }); + await h.agent.resumeSession(params); + const retry = h.early("retry"); + try { + await h.client.waitForSessionNotifications(sessionId); + expect(await retry.response).toEqual({decision: "accept"}); + expect(h.text()).toEqual(["live retry"]); + expect(retryState.currentTurnId).toBe("retry"); + expect(h.events().filter(event => event.method === "requestPermission")).toHaveLength(1); + } finally { + await h.emit("turn/completed", {turn: turn("retry", "completed")}); + } + }, + ); +}); diff --git a/src/__tests__/CodexACPAgent/session-queue-review.test.ts b/src/__tests__/CodexACPAgent/session-queue-review.test.ts new file mode 100644 index 000000000..8d65b38d6 --- /dev/null +++ b/src/__tests__/CodexACPAgent/session-queue-review.test.ts @@ -0,0 +1,254 @@ +import {describe, expect, it, vi} from "vitest"; +import {createCodexMockTestFixture, createTestSessionState} from "../acp-test-utils"; +import {SESSION_QUEUE_ACTIONS, type SessionQueueNative} from "../../SessionQueue"; +import type {SessionState} from "../../CodexAcpServer"; +import {OPENAI_PROVIDER_ID} from "../../CodexAcpClient"; +import type {ThreadReadResponse, Turn, TurnCompletedNotification} from "../../app-server/v2"; + +const sessionId = "session-id"; +const turn = (id: string, status: Turn["status"] = "inProgress"): Turn => ({ + id, status, items: [], itemsView: "notLoaded", error: null, + startedAt: null, completedAt: null, durationMs: null, +}); + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise(r => { resolve = r; }); + return {promise, resolve}; +} + +async function setup() { + const f = createCodexMockTestFixture(); + const agent = f.getCodexAcpAgent(); + const client = f.getCodexAcpClient(); + const native = f.getCodexAppServerClient(); + client.queueSupport = {nativeVersion: "0.160.1", actions: SESSION_QUEUE_ACTIONS}; + const state = createTestSessionState(); + (agent as unknown as {sessions: Map}).sessions.set(sessionId, state); + const submission = {id: "q", clientUserMessageId: "client", input: []}; + const queue = { + list: vi.fn().mockResolvedValue({data: [], nextCursor: null}), + add: vi.fn().mockResolvedValue({queuedSubmission: submission}), + update: vi.fn().mockResolvedValue({queuedSubmission: submission}), + delete: vi.fn().mockResolvedValue({deleted: true}), + reorder: vi.fn().mockResolvedValue({}), + start: vi.fn().mockResolvedValue({turn: turn("queued")}), + } satisfies SessionQueueNative; + vi.spyOn(native, "queueNative").mockReturnValue(queue); + // Prompt admission only consumes the status from this metadata read. + vi.spyOn(native, "threadRead").mockResolvedValue({ + thread: {id: sessionId, status: {type: "idle"}}, + } as ThreadReadResponse); + const request = (action: string, extra: Record = {}) => + agent.manageSessionQueue({sessionId, action, ...extra}); + await request("list"); + const emit = async (method: string, params: object = {}) => { + f.sendServerNotification({method, params: {threadId: sessionId, ...params}}); + await client.waitForSessionNotifications(sessionId); + }; + const notifications = (method: string) => f.getAcpConnectionEvents([]) + .filter(event => event.method === "notify" && event.args[0] === method) + .map(event => event.args[1]); + const approval = (turnId: string) => f.sendServerRequest("item/commandExecution/requestApproval", { + threadId: sessionId, turnId, itemId: `cmd-${turnId}`, approvalId: null, + kind: "command", startedAtMs: 0, environmentId: null, + command: `echo ${turnId}`, cwd: "/test/cwd", reason: null, + availableDecisions: ["accept", "decline"], + }); + return {f, agent, client, native, state, queue, request, emit, notifications, approval}; +} + +describe("native queue review regressions", () => { + it("invalidates the provider on an unanswered durable queue write and refuses a late retry", async () => { + const {agent, queue, request} = await setup(); + vi.useFakeTimers(); + try { + const pending = deferred>>(); + queue.add.mockReturnValueOnce(pending.promise); + const result = request("add", {input: [], clientUserMessageId: "client"}); + const failed = expect(result).rejects.toThrow("timed out"); + await vi.advanceTimersByTimeAsync(30_000); + await failed; + pending.resolve({queuedSubmission: {id: "q", clientUserMessageId: "client", input: []}}); + await Promise.resolve(); + await expect(request("add", {input: [], clientUserMessageId: "client"})).rejects.toThrow(); + await expect(agent.prompt({sessionId, prompt: []})).rejects.toThrow(); + expect(queue.add).toHaveBeenCalledOnce(); + await expect(agent.closeSession({sessionId})).resolves.toEqual({}); + } finally { vi.useRealTimers(); } + }); + it.each(["set", "disable"] as const)("rejects provider %s before changing configuration during a queued turn", async action => { + const {agent, client, emit} = await setup(); + client.setProvider({ + providerId: OPENAI_PROVIDER_ID, apiType: "openai", baseUrl: "https://original.example/v1", + }); + const before = agent.listProviders({}); + await emit("turn/started", {turn: turn("queued")}); + try { + const changing = action === "set" + ? agent.setProvider({ + providerId: OPENAI_PROVIDER_ID, apiType: "openai", baseUrl: "https://replacement.example/v1", + }) + : agent.disableProvider({providerId: OPENAI_PROVIDER_ID}); + await expect(changing).rejects.toMatchObject({code: -32600}); + expect(agent.listProviders({})).toEqual(before); + } finally { + await emit("turn/completed", {turn: turn("queued", "completed")}); + } + }); + + it.each(["local command", "failed native start"] as const)( + "owns queued work after a prompt exits through %s without turn/completed", + async exit => { + const {f, agent, native, request, emit, notifications, client, state} = await setup(); + const start = vi.spyOn(native, "turnStart"); + if (exit === "local command") { + // Missing rename arguments only produce a local usage message. + await expect(agent.prompt({sessionId, prompt: [{type: "text", text: "/rename"}]})) + .resolves.toMatchObject({stopReason: "end_turn"}); + expect(start).not.toHaveBeenCalled(); + } else { + start.mockRejectedValueOnce(new Error("native start rejected")); + await expect(agent.prompt({sessionId, prompt: [{type: "text", text: "hello"}]})) + .rejects.toThrow("native start rejected"); + expect(start).toHaveBeenCalledOnce(); + } + + await request("add", {input: [], clientUserMessageId: "client"}); + await emit("turn/started", {turn: turn("queued")}); + await emit("item/agentMessage/delta", {turnId: "queued", itemId: "answer", delta: "queued output"}); + try { + expect(notifications("_session/queue/turn")).toEqual([{sessionId, turn: turn("queued")}]); + expect(state.currentTurnId).toBe("queued"); + expect(f.getAcpConnectionEvents([]).filter(event => event.method === "sessionUpdate" + && event.args[0].update.content?.text === "queued output")).toHaveLength(1); + await expect(agent.prompt({sessionId, prompt: []})).rejects.toMatchObject({ + data: expect.stringContaining("queued turn"), + }); + const interrupt = vi.spyOn(client, "turnInterrupt").mockResolvedValue(); + await agent.cancel({sessionId}); + expect(interrupt).toHaveBeenCalledWith({threadId: sessionId, turnId: "queued"}); + } finally { + await emit("turn/completed", {turn: turn("queued", "interrupted")}); + } + expect(notifications("_session/queue/turn")).toEqual([ + {sessionId, turn: turn("queued")}, + {sessionId, turn: turn("queued", "interrupted")}, + ]); + }, + ); + + it.each(["add", "start"] as const)("remembers cancel while queue %s is awaiting turn/started", async action => { + const {agent, client, queue, request, emit, state} = await setup(); + const dispatched = deferred(); + const release = deferred(); + const interrupt = vi.spyOn(client, "turnInterrupt").mockResolvedValue(); + if (action === "add") { + queue.add.mockImplementationOnce(async () => { + dispatched.resolve(); + await release.promise; + await emit("turn/started", {turn: turn("queued")}); + return {queuedSubmission: {id: "q", clientUserMessageId: "client", input: []}}; + }); + } else { + queue.start.mockImplementationOnce(async () => { + dispatched.resolve(); + await release.promise; + await emit("turn/started", {turn: turn("queued")}); + return {turn: turn("queued")}; + }); + } + const pending = request(action, action === "add" ? {input: [], clientUserMessageId: "client"} : {}); + await dispatched.promise; + try { + expect(state.currentTurnId).toBeNull(); + await agent.cancel({sessionId}); + expect(interrupt).not.toHaveBeenCalled(); + } finally { + release.resolve(); + await pending; + } + try { + expect(interrupt).toHaveBeenCalledExactlyOnceWith({threadId: sessionId, turnId: "queued"}); + } finally { + await emit("turn/completed", {turn: turn("queued", "interrupted")}); + } + }); + + it("rejects an approval delayed across completion and successor start without rejecting the successor", async () => { + const {f, client, emit, approval} = await setup(); + await emit("turn/started", {turn: turn("old")}); + f.setPermissionResponse({outcome: {outcome: "selected", optionId: "allow_once"}}); + const waiting = deferred(); + const release = deferred(); + const drain = client.waitForSessionNotifications.bind(client); + // Pause only the interactive request's notification barrier. Normal event delivery remains live. + const barrier = vi.spyOn(client, "waitForSessionNotifications").mockImplementationOnce(async id => { + waiting.resolve(); + await release.promise; + await drain(id); + }); + const stale = approval("old"); + await waiting.promise; + try { + await emit("turn/completed", {turn: turn("old", "completed")}); + await emit("turn/started", {turn: turn("new")}); + } finally { + release.resolve(); + barrier.mockRestore(); + } + try { + expect(await stale).toEqual({decision: "cancel"}); + expect(f.getAcpConnectionEvents([]).filter(event => event.method === "requestPermission")).toEqual([]); + expect(await approval("new")).toEqual({decision: "accept"}); + expect(f.getAcpConnectionEvents([]).filter(event => event.method === "requestPermission")).toHaveLength(1); + } finally { + await emit("turn/completed", {turn: turn("new", "completed")}); + } + }); + + it("publishes each queue invalidation exactly once during an ordinary active prompt", async () => { + const {agent, native, request, queue, emit, notifications} = await setup(); + const awaitingCompletion = deferred(); + const completed = deferred(); + vi.spyOn(native, "turnStart").mockImplementationOnce(async () => { + await emit("turn/started", {turn: turn("normal")}); + return {turn: turn("normal")}; + }); + vi.spyOn(native, "awaitTurnCompleted").mockImplementationOnce(() => { + awaitingCompletion.resolve(); + return completed.promise; + }); + const prompt = agent.prompt({sessionId, prompt: [{type: "text", text: "normal"}]}); + await Promise.race([ + awaitingCompletion.promise, + prompt.then(() => { throw new Error("Prompt completed before the native turn was awaited"); }), + ]); + queue.add.mockImplementationOnce(async () => { + await emit("thread/queue/changed"); + return {queuedSubmission: {id: "q", clientUserMessageId: "client", input: []}}; + }); + queue.update.mockImplementationOnce(async () => { + await emit("thread/queue/changed"); + return {queuedSubmission: {id: "q", clientUserMessageId: "client", input: []}}; + }); + queue.reorder.mockImplementationOnce(async () => { + await emit("thread/queue/changed"); + return {}; + }); + try { + await request("add", {input: [], clientUserMessageId: "client"}); + expect(notifications("_session/queue/changed")).toEqual([{sessionId}]); + await request("update", {queuedSubmissionId: "q", input: []}); + expect(notifications("_session/queue/changed")).toEqual([{sessionId}, {sessionId}]); + await request("reorder", {queuedSubmissionIds: ["q"]}); + expect(notifications("_session/queue/changed")).toEqual([{sessionId}, {sessionId}, {sessionId}]); + expect(notifications("_session/queue/turn")).toEqual([]); + } finally { + const terminal = {threadId: sessionId, turn: turn("normal", "completed")}; + await emit("turn/completed", terminal); + completed.resolve(terminal); + await prompt; + } + }); +}); diff --git a/src/__tests__/CodexACPAgent/session-queue-server-final-review.test.ts b/src/__tests__/CodexACPAgent/session-queue-server-final-review.test.ts new file mode 100644 index 000000000..e143abc47 --- /dev/null +++ b/src/__tests__/CodexACPAgent/session-queue-server-final-review.test.ts @@ -0,0 +1,82 @@ +import {describe, expect, it, vi} from "vitest"; +import {createCodexMockTestFixture, createTestSessionState} from "../acp-test-utils"; +import type {SessionState} from "../../CodexAcpServer"; +import {SESSION_QUEUE_ACTIONS} from "../../SessionQueue"; +import type {Turn} from "../../app-server/v2"; + +const sessionId = "session-id"; +const turn: Turn = {id: "early", status: "inProgress", items: [], itemsView: "notLoaded", error: null, + startedAt: null, completedAt: null, durationMs: null}; +const request = {sessionId, cwd: "/test/cwd", mcpServers: []}; +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise(r => { resolve = r; }); + return {promise, resolve}; +} +function setup() { + const f = createCodexMockTestFixture(); + const agent = f.getCodexAcpAgent(); + const client = f.getCodexAcpClient(); + client.queueSupport = {nativeVersion: "0.160.1", actions: SESSION_QUEUE_ACTIONS}; + const boundary = agent as unknown as { + installSessionState(state: SessionState): void; + getOrCreateSessionWithHistory: (...args: unknown[]) => Promise; + streamThreadHistory: (...args: unknown[]) => Promise; + beginSessionOpen(id: string): number; + cleanupStaleSessionOpen(id: string, generation: number): Promise; + }; + const state = createTestSessionState(); + const start = () => f.sendServerNotification({method: "turn/started", params: {threadId: sessionId, turn}}); + const complete = () => f.sendServerNotification({method: "turn/completed", params: {threadId: sessionId, turn: {...turn, status: "interrupted"}}}); + return {f, agent, client, boundary, state, start, complete}; +} + +describe("maintainer final server failure paths", () => { + it("interrupts a raw native turn immediately while cold history replay is blocked", async () => { + const h = setup(); + const replayEntered = deferred(); + const release = deferred(); + vi.spyOn(h.boundary, "getOrCreateSessionWithHistory").mockImplementationOnce(async () => { + h.start(); + h.boundary.installSessionState(h.state); + return {sessionId, modelState: {currentModelId: h.state.currentModelId, availableModels: []}, + modeState: h.state.agentMode.toSessionModeState(false), thread: {id: sessionId}, + history: (async function* () {})()}; + }); + vi.spyOn(h.boundary, "streamThreadHistory").mockImplementationOnce(async () => { + replayEntered.resolve(); + await release.promise; + }); + const interrupt = vi.spyOn(h.client, "turnInterrupt").mockResolvedValue(); + const load = h.agent.loadSession(request); + await replayEntered.promise; + try { + await h.agent.cancel({sessionId}); + expect(interrupt).toHaveBeenCalledWith({threadId: sessionId, turnId: "early"}); + } finally { + release.resolve(); + await load; + await h.client.waitForSessionNotifications(sessionId); + h.complete(); + await h.client.waitForSessionNotifications(sessionId); + } + }); + + it("interrupts before metadata-failure helper releases the native subscription", async () => { + const h = setup(); + const calls: string[] = []; + vi.spyOn(h.client, "turnInterrupt").mockImplementation(async () => { calls.push("interrupt"); }); + const close = h.client.closeSession.bind(h.client); + vi.spyOn(h.client, "closeSession").mockImplementation(async id => { calls.push("unsubscribe"); await close(id); }); + vi.spyOn(h.boundary, "getOrCreateSessionWithHistory").mockImplementationOnce(async () => { + const generation = h.boundary.beginSessionOpen(sessionId); + h.start(); + await h.boundary.cleanupStaleSessionOpen(sessionId, generation); + throw new Error("metadata failed after subscription"); + }); + await expect(h.agent.loadSession(request)).rejects.toThrow("metadata failed after subscription"); + await h.client.waitForSessionNotifications(sessionId); + expect(calls[0]).toBe("interrupt"); + expect(calls).toContain("unsubscribe"); + }); +}); diff --git a/src/__tests__/CodexACPAgent/session-queue-subagents.test.ts b/src/__tests__/CodexACPAgent/session-queue-subagents.test.ts new file mode 100644 index 000000000..d14eb3219 --- /dev/null +++ b/src/__tests__/CodexACPAgent/session-queue-subagents.test.ts @@ -0,0 +1,238 @@ +import {describe, expect, it, vi} from "vitest"; +import {createCodexMockTestFixture, createTestSessionState} from "../acp-test-utils"; +import {ACPSessionConnection, type AcpClientConnection} from "../../ACPSessionConnection"; +import type {SessionState} from "../../CodexAcpServer"; +import {SESSION_QUEUE_ACTIONS, type SessionQueueNative} from "../../SessionQueue"; +import {CodexSubagentEventRouter} from "../../subagents/CodexSubagentEventRouter"; +import type {ThreadItem, ThreadReadResponse, Turn, TurnCompletedNotification} from "../../app-server/v2"; + +const sessionId = "session-id"; +const turn = (id: string, status: Turn["status"] = "inProgress"): Turn => ({ + id, status, items: [], itemsView: "notLoaded", error: null, + startedAt: null, completedAt: null, durationMs: null, +}); +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise(r => { resolve = r; }); + return {promise, resolve}; +} +const checkpoint = () => new Promise(resolve => setImmediate(resolve)); + +async function setup(supportsSubagents = true, supportsSessionIndex = false) { + const f = createCodexMockTestFixture(); + const agent = f.getCodexAcpAgent(); + const client = f.getCodexAcpClient(); + const native = f.getCodexAppServerClient(); + // The shared fixture ignores request cancellation. Match the real ACP boundary + // so a retired child's aborted context cannot accidentally accept a permission. + const connection = f.getAcpConnection(); + const abortAware = new Proxy(connection, { + get(target, property) { + if (property !== "request") return Reflect.get(target, property); + return (...args: Parameters) => { + if (args[2]?.cancellationSignal?.aborted) return Promise.reject(new Error("Permission context aborted")); + return Reflect.get(target, property)(...args); + }; + }, + }); + (agent as unknown as {connection: AcpClientConnection}).connection = abortAware; + await agent.initialize({ + protocolVersion: 1, + clientCapabilities: {_meta: {jetbrains: {air: { + version: 1, capabilities: [ + ...(supportsSubagents ? ["nativeSubagentSessions"] : []), + ...(supportsSessionIndex ? ["sessionIndex"] : []), + ], + }}}}, + }); + client.queueSupport = {nativeVersion: "0.160.1", actions: SESSION_QUEUE_ACTIONS}; + const state = createTestSessionState(); + state.subagents = new CodexSubagentEventRouter( + sessionId, supportsSubagents, new ACPSessionConnection(f.getAcpConnection(), sessionId), + id => state.toolCallReports.releaseOpen(id), + ); + (agent as unknown as {sessions: Map}).sessions.set(sessionId, state); + const submission = {id: "q", clientUserMessageId: "client", input: []}; + const queue = { + list: vi.fn().mockResolvedValue({data: [], nextCursor: null}), + add: vi.fn().mockResolvedValue({queuedSubmission: submission}), + update: vi.fn().mockResolvedValue({queuedSubmission: submission}), + delete: vi.fn().mockResolvedValue({deleted: true}), + reorder: vi.fn().mockResolvedValue({}), + start: vi.fn().mockResolvedValue({turn: turn("queued")}), + } satisfies SessionQueueNative; + vi.spyOn(native, "queueNative").mockReturnValue(queue); + vi.spyOn(native, "threadRead").mockResolvedValue({thread: {id: sessionId, status: {type: "idle"}}} as ThreadReadResponse); + await agent.manageSessionQueue({sessionId, action: "list"}); + f.clearAcpConnectionDump(); + f.setPermissionResponse({outcome: {outcome: "selected", optionId: "allow_once"}}); + const emit = async (method: string, params: object, threadId = sessionId) => { + f.sendServerNotification({method, params: {threadId, ...params}}); + await client.waitForSessionNotifications(sessionId); + }; + const spawn = async (parentTurn: string, childId: string) => { + const item: ThreadItem = { + type: "collabAgentToolCall", id: `spawn-${childId}`, tool: "spawnAgent", status: "completed", + senderThreadId: sessionId, receiverThreadIds: [childId], prompt: "Child task", + model: null, reasoningEffort: null, agentsStates: {[childId]: {status: "running", message: null}}, + }; + await emit("item/completed", {turnId: parentTurn, completedAtMs: 0, item}); + await emit("item/started", { + turnId: parentTurn, startedAtMs: 0, + item: {type: "subAgentActivity", id: `activity-${childId}`, kind: "started", + agentThreadId: childId, agentPath: `/root/${childId}`} satisfies ThreadItem, + }); + await emit("turn/started", {turn: turn(`turn-${childId}`)}, childId); + }; + const approval = (childId: string, itemId: string, turnId = `turn-${childId}`) => f.sendServerRequest("item/commandExecution/requestApproval", { + threadId: childId, turnId, itemId, approvalId: null, + kind: "command", startedAtMs: 0, environmentId: null, + command: `echo ${itemId}`, cwd: "/test/cwd", availableDecisions: ["accept", "decline"], + }); + const updates = () => f.getAcpConnectionEvents([]).filter(event => event.method === "sessionUpdate").map(event => event.args[0]); + const childTerminals = (id: string) => updates().filter(event => event.update.sessionUpdate === "subagent_state_update" + && event.update.subagentSessionId === id).map(event => event.update.state); + const startPrompt = async () => { + const entered = deferred(); + const completed = deferred(); + vi.spyOn(native, "turnStart").mockImplementationOnce(async () => { + await emit("turn/started", {turn: turn("normal")}); + return {turn: turn("normal")}; + }); + vi.spyOn(native, "awaitTurnCompleted").mockImplementationOnce(() => { + entered.resolve(); + return completed.promise; + }); + let settled = false; + const result = agent.prompt({sessionId, prompt: [{type: "text", text: "normal"}]}).then( + response => { settled = true; return {response}; }, + error => { settled = true; return {error}; }, + ); + await Promise.race([entered.promise, result.then(() => { throw new Error("Prompt ended before native completion wait"); })]); + return {completed, result, settled: () => settled}; + }; + return {f, agent, client, state, emit, spawn, approval, updates, childTerminals, startPrompt}; +} + +describe("native queue child ownership", () => { + it.each(["AIR entry", "direct index"])("blocks %s archive for active and retained queue work without closing it", async route => { + const h = await setup(true, true); + const close = vi.spyOn(h.agent, "closeSession"); + const archive = () => route === "AIR entry" + ? h.agent.setSessionArchived({sessionId}, true) + : h.agent.sessionIndex.setArchived({sessionId}, true); + expect(h.agent.sessionIndex.enabled).toBe(true); + await h.emit("turn/started", {turn: turn("queued")}); + try { + await expect(archive()).rejects.toMatchObject({data: expect.stringContaining("Queued work is active")}); + await h.spawn("queued", "retained-child"); + await h.emit("turn/completed", {turn: turn("queued", "completed")}); + await checkpoint(); + await expect(archive()).rejects.toMatchObject({data: expect.stringContaining("Queued work is active")}); + expect(close).not.toHaveBeenCalled(); + expect(h.f.getCodexConnectionEvents([]).filter(event => event.eventType === "request" + && event.method === "thread/archive")).toEqual([]); + expect(await h.approval("retained-child", "still-owned")).toEqual({decision: "accept"}); + } finally { + await h.emit("turn/completed", {turn: turn("turn-retained-child", "completed")}, "retained-child"); + await h.emit("turn/completed", {turn: turn("queued", "completed")}); + } + await vi.waitFor(() => expect((h.agent as unknown as {queueOwners: Map}) + .queueOwners.get(sessionId)?.active()).toBe(false)); + await expect(archive()).resolves.toEqual({}); + expect(close).toHaveBeenCalledOnce(); + }); + + it("routes a reused child thread to the queued turn that sends its new input", async () => { + const h = await setup(); + await h.emit("turn/started", {turn: turn("first")}); + await h.spawn("first", "reused"); + await h.emit("turn/completed", {turn: turn("turn-reused", "completed")}, "reused"); + await h.emit("turn/completed", {turn: turn("first", "completed")}); + await checkpoint(); + await h.emit("turn/started", {turn: turn("second")}); + await h.emit("item/completed", {turnId: "second", completedAtMs: 0, item: { + type: "collabAgentToolCall", id: "reuse", tool: "sendInput", status: "completed", + senderThreadId: sessionId, receiverThreadIds: ["reused"], prompt: "Follow-up", + model: null, reasoningEffort: null, agentsStates: {reused: {status: "running", message: null}}, + } satisfies ThreadItem}); + await h.emit("item/started", {turnId: "second", startedAtMs: 0, item: { + type: "subAgentActivity", id: "reuse-activity", kind: "started", + agentThreadId: "reused", agentPath: "/root/reused", + } satisfies ThreadItem}); + await h.emit("turn/started", {turn: turn("turn-reused-2")}, "reused"); + try { + expect(await h.approval("reused", "new-input", "turn-reused-2")).toEqual({decision: "accept"}); + await h.emit("item/agentMessage/delta", { + turnId: "turn-reused-2", itemId: "reused-answer", delta: "follow-up output", + }, "reused"); + expect(h.updates().filter(event => event.update.content?.text === "follow-up output")).toHaveLength(1); + } finally { + await h.emit("turn/completed", {turn: turn("turn-reused-2", "completed")}, "reused"); + await h.emit("turn/completed", {turn: turn("second", "interrupted")}); + } + }); + + it.each([ + {root: "queued", visible: true}, {root: "queued", visible: false}, + {root: "normal", visible: true}, {root: "normal", visible: false}, + ] as const)("keeps $root child approvals alive after root completion and queue handoff (visible=$visible)", async ({root, visible}) => { + const h = await setup(visible); + const prompt = root === "normal" ? await h.startPrompt() : undefined; + if (root === "queued") await h.emit("turn/started", {turn: turn(root)}); + await h.spawn(root, "child-a"); + const terminal = {threadId: sessionId, turn: turn(root, "completed")}; + await h.emit("turn/completed", terminal); + let successorStarted = false; + try { + expect(await h.approval("child-a", "after-parent")).toEqual({decision: "accept"}); + await h.emit("turn/started", {turn: turn("successor")}); + successorStarted = true; + expect(await h.approval("child-a", "after-successor")).toEqual({decision: "accept"}); + const requests = h.f.getAcpConnectionEvents([]).filter(event => event.method === "requestPermission"); + expect(requests.map(event => event.args[0].sessionId)).toEqual([ + visible ? "child-a" : sessionId, visible ? "child-a" : sessionId, + ]); + expect(h.childTerminals("child-a")).toEqual([]); + await h.emit("turn/completed", {turn: turn("turn-child-a", "completed")}, "child-a"); + expect(await h.approval("child-a", "stale-child")).toEqual({decision: "cancel"}); + expect(h.f.getAcpConnectionEvents([]).filter(event => event.method === "requestPermission")).toHaveLength(2); + if (visible) expect(h.childTerminals("child-a")).toEqual(["completed"]); + } finally { + await h.emit("turn/completed", {turn: turn("turn-child-a", "completed")}, "child-a"); + if (successorStarted) await h.emit("turn/completed", {turn: turn("successor", "completed")}); + prompt?.completed.resolve(terminal); + await prompt?.result; + } + }); + + it.each(["completed", "failed"] as const)("does not let a %s ordinary prompt wait on or finalize successor children", async status => { + const h = await setup(); + const prompt = await h.startPrompt(); + const terminal = {threadId: sessionId, turn: turn("normal", status)}; + await h.emit("turn/completed", terminal); + await h.emit("turn/started", {turn: turn("successor")}); + await h.spawn("successor", "child-b"); + expect(h.updates().filter(event => event.update.sessionUpdate === "subagent_spawned" + && event.update.subagentSessionId === "child-b")).toHaveLength(1); + prompt.completed.resolve(terminal); + try { + await checkpoint(); + // B's child stays open until its own native terminal event, even if A failed. + expect(h.childTerminals("child-b")).toEqual([]); + expect(prompt.settled()).toBe(true); + expect(h.state.currentTurnId).toBe("successor"); + expect(await h.approval("child-b", "survived-cleanup")).toEqual({decision: "accept"}); + await h.emit("item/agentMessage/delta", { + turnId: "turn-child-b", itemId: "answer-b", delta: "child survived", + }, "child-b"); + expect(h.updates().filter(event => event.sessionId === "child-b" + && event.update.content?.text === "child survived")).toHaveLength(1); + } finally { + await h.emit("turn/completed", {turn: turn("turn-child-b", "completed")}, "child-b"); + await h.emit("turn/completed", {turn: turn("successor", "completed")}); + await prompt.result; + } + expect(h.childTerminals("child-b")).toEqual(["completed"]); + }); +}); diff --git a/src/__tests__/CodexACPAgent/session-queue-upstream.test.ts b/src/__tests__/CodexACPAgent/session-queue-upstream.test.ts new file mode 100644 index 000000000..675c77598 --- /dev/null +++ b/src/__tests__/CodexACPAgent/session-queue-upstream.test.ts @@ -0,0 +1,194 @@ +import {describe, expect, it, vi} from "vitest"; +import {ResponseError} from "vscode-jsonrpc/node"; +import {SESSION_QUEUE_ACTIONS} from "../../SessionQueue"; +import {createRecoveryFixture, defaultAnswer, initialize, requestsOf, type RecoveryFixture} from "../app-server-recovery/recovery-fixture"; + +const sessionId = "thread-new"; +const add = {sessionId, action: "add", input: [{type: "text", text: "queued"}], clientUserMessageId: "client-1"}; +const queued = {queuedSubmission: {id: "q-1", input: add.input, clientUserMessageId: add.clientUserMessageId}}; +const turn = (id = "queued-turn", status = "inProgress") => ({id, status, items: [], itemsView: "notLoaded", error: null, + startedAt: null, completedAt: null, durationMs: null}); +const capability = {_meta: {jetbrains: {air: {version: 1, capabilities: ["sessionIndex"]}}}}; +const writes = (f: RecoveryFixture, action: string) => f.servers.flatMap(s => requestsOf(s, `thread/queue/${action}`)) + .filter(p => (p as {threadId?: string}).threadId); + +async function setup(open = true) { + const f = createRecoveryFixture(); + f.answers.set("initialize", () => ({userAgent: "codex/0.160.1 (fixture)", codexHome: "/codex-home"})); + for (const action of SESSION_QUEUE_ACTIONS) { + f.answers.set(`thread/queue/${action}`, params => { + if (!(params as {threadId?: string}).threadId) return new ResponseError(-32600, "Invalid request: missing field `threadId`"); + if (action === "list") return {data: [], nextCursor: null}; + if (action === "start") return {turn: turn()}; + if (action === "delete") return {deleted: true}; + return action === "reorder" ? {} : queued; + }); + } + f.answers.set("thread/read", params => ({thread: { + ...(defaultAnswer("thread/resume", params) as {thread: object}).thread, + path: "/codex-home/sessions/fixture.jsonl", status: {type: open ? "idle" : "notLoaded"}, + }})); + await initialize(f, true, capability); + if (open) await f.agent.newSession({cwd: "/work", mcpServers: []}); + return f; +} + +const indexMutation = (f: RecoveryFixture, action: string) => action === "rename" + ? f.agent.sessionIndex.rename({sessionId, title: "Renamed"}) + : f.agent.sessionIndex.setArchived({sessionId}, action === "archive"); + +describe("native queue with upstream recovery and session index", () => { + it("restarts and reprobes before reading a cold queue without resuming it", async () => { + const f = await setup(false); + await f.kill(); + await expect(f.agent.manageSessionQueue({sessionId, action: "list"})).resolves.toMatchObject({status: "ok"}); + expect(f.servers).toHaveLength(2); + expect(writes(f, "list")).toHaveLength(1); + expect(requestsOf(f.current(), "thread/resume")).toEqual([]); + for (const action of SESSION_QUEUE_ACTIONS) expect(requestsOf(f.current(), `thread/queue/${action}`)).toContainEqual({}); + }); + + it("fails closed for a stale loaded session, then accepts one write after explicit load", async () => { + const f = await setup(); + await f.kill(); + await expect(f.agent.manageSessionQueue(add)).rejects.toMatchObject({code: -32600, data: expect.stringContaining("Load the session")}); + expect(writes(f, "add")).toEqual([]); + expect(requestsOf(f.current(), "thread/resume")).toEqual([]); + await f.agent.loadSession({sessionId, cwd: "/work", mcpServers: []}); + await expect(f.agent.manageSessionQueue(add)).resolves.toMatchObject({status: "ok"}); + expect(writes(f, "add")).toHaveLength(1); + }); + + it("never replays a write whose ACK was lost, even when another request restarts the server", async () => { + const f = await setup(); + f.answers.set("thread/queue/add", p => (p as {threadId?: string}).threadId + ? undefined : new ResponseError(-32600, "Invalid request: missing field `threadId`")); + const pending = f.agent.manageSessionQueue(add); + const rejected = expect(pending).rejects.toBeDefined(); + await vi.waitFor(() => expect(writes(f, "add")).toHaveLength(1)); + await f.kill(); + await rejected; + await f.agent.listSessions({}); + await expect(f.agent.manageSessionQueue(add)).rejects.toMatchObject({code: -32600, data: expect.stringContaining("reconcile")}); + expect(writes(f, "add")).toHaveLength(1); + expect(requestsOf(f.current(), "thread/resume")).toEqual([]); + }); + + it("rechecks support on the replacement rather than using the dead server's capability", async () => { + const f = await setup(false); + await f.kill(); + f.answers.set("initialize", () => ({userAgent: "codex/0.159.0 (fixture)", codexHome: "/codex-home"})); + await expect(f.agent.manageSessionQueue(add)).resolves.toEqual({status: "unsupported"}); + expect(writes(f, "add")).toEqual([]); + }); + + it("requires explicit load instead of auto-dispatching persisted work during lazy prompt recovery", async () => { + const f = await setup(); + await f.kill(); + f.answers.set("thread/queue/list", params => (params as {threadId?: string}).threadId + ? {data: [queued.queuedSubmission], nextCursor: null} + : new ResponseError(-32600, "Invalid request: missing field `threadId`")); + await expect(f.agent.prompt({sessionId, prompt: [{type: "text", text: "ordinary"}]})).rejects.toBeDefined(); + expect(requestsOf(f.current(), "thread/resume")).toEqual([]); + expect(requestsOf(f.current(), "turn/start")).toEqual([]); + expect(writes(f, "add")).toEqual([]); + }); + + it("owns a resumed queue before metadata completes and interrupts it during cold load", async () => { + const f = await setup(); + await f.kill(); + f.answers.set("thread/resume", (params, server) => { + server.rpc.notify({method: "turn/started", params: {threadId: sessionId, turn: turn()}}); + return defaultAnswer("thread/resume", params); + }); + f.answers.set("model/list", () => undefined); + const loading = f.agent.loadSession({sessionId, cwd: "/work", mcpServers: []}); + loading.catch(() => undefined); + await vi.waitFor(() => expect(f.servers.length === 2 && f.current().rpc.hasPending("model/list")).toBe(true)); + await f.agent.cancel({sessionId}); + expect(requestsOf(f.current(), "turn/interrupt")).toEqual([{threadId: sessionId, turnId: "queued-turn"}]); + f.current().rpc.notify({method: "turn/completed", params: {threadId: sessionId, turn: turn("queued-turn", "interrupted")}}); + f.current().rpc.resolve("model/list", defaultAnswer("model/list", {})); + await loading; + }); + + it("discards the crashed owner's events and publishes the resumed owner's output", async () => { + const f = await setup(); + const previous = f.current(); + previous.rpc.notify({method: "turn/started", params: {threadId: sessionId, turn: turn("old")}}); + await f.kill(); + await f.agent.loadSession({sessionId, cwd: "/work", mcpServers: []}); + previous.rpc.notify({method: "item/agentMessage/delta", params: {threadId: sessionId, turnId: "old", itemId: "old", delta: "stale output"}}); + f.current().rpc.notify({method: "turn/started", params: {threadId: sessionId, turn: turn("new")}}); + f.current().rpc.notify({method: "item/agentMessage/delta", params: {threadId: sessionId, turnId: "new", itemId: "new", delta: "fresh output"}}); + await vi.waitFor(() => expect(f.updates().map(x => x.update)).toContainEqual(expect.objectContaining({content: {type: "text", text: "fresh output"}}))); + expect(f.updates().map(x => x.update)).not.toContainEqual(expect.objectContaining({content: {type: "text", text: "stale output"}})); + f.current().rpc.notify({method: "turn/completed", params: {threadId: sessionId, turn: turn("new", "completed")}}); + }); + + it("unblocks a queue request waiting for a provider replacement that crashed without replaying it", async () => { + const f = await setup(); + f.answers.set("thread/resume", () => undefined); + const update = f.agent.setProvider({providerId: "openai", apiType: "openai", baseUrl: "http://127.0.0.1:1/v1"}); + const rejectedUpdate = expect(update).rejects.toBeDefined(); + await vi.waitFor(() => expect(f.servers.length === 2 && f.current().rpc.hasPending("thread/resume")).toBe(true)); + const queue = f.agent.manageSessionQueue(add); + const rejectedQueue = expect(queue).rejects.toMatchObject({code: -32600, data: expect.stringContaining("Load the session")}); + await f.kill(); + await rejectedUpdate; + await rejectedQueue; + expect(writes(f, "add")).toEqual([]); + expect(f.servers).toHaveLength(3); + }); + + it("lets close settle a prompt that a provider update is waiting for", async () => { + const f = await setup(); + const prompt = f.agent.prompt({sessionId, prompt: [{type: "text", text: "ordinary"}]}); + prompt.catch(() => undefined); + await vi.waitFor(() => expect(f.agent.getSessionState(sessionId).currentTurnId).not.toBeNull()); + const update = f.agent.setProvider({providerId: "openai", apiType: "openai", baseUrl: "http://127.0.0.1:1/v1"}); + update.catch(() => undefined); + await f.agent.closeSession({sessionId}); + await expect(prompt).resolves.toMatchObject({stopReason: "cancelled"}); + // The queue check fails replacement closed when close wins the race; + // it must not keep close waiting on the update that awaits this prompt. + await expect(update).rejects.toMatchObject({code: -32600, data: `Session ${sessionId} is closing`}); + expect(requestsOf(f.current(), "turn/interrupt")).toHaveLength(1); + }); + + it.each(["archive", "unarchive", "rename"])("blocks direct index %s during a queue write", async action => { + const f = await setup(false); + f.answers.set("thread/queue/add", () => undefined); + const pending = f.agent.manageSessionQueue(add); + await vi.waitFor(() => expect(writes(f, "add")).toHaveLength(1)); + await expect(indexMutation(f, action)).rejects.toMatchObject({code: -32600, data: expect.stringContaining("Queue mutation")}); + expect(requestsOf(f.current(), "thread/archive")).toEqual([]); + expect(requestsOf(f.current(), "thread/unarchive")).toEqual([]); + expect(requestsOf(f.current(), "thread/name/set")).toEqual([]); + f.current().rpc.resolve("thread/queue/add", queued); + await pending; + }); + + it.each(["archive", "unarchive", "rename"])("blocks direct index %s until cancellation settles", async action => { + const f = await setup(); + f.current().rpc.notify({method: "turn/started", params: {threadId: sessionId, turn: turn()}}); + f.answers.set("turn/interrupt", () => undefined); + const cancel = f.agent.cancel({sessionId}); + await vi.waitFor(() => expect(f.current().rpc.hasPending("turn/interrupt")).toBe(true)); + await expect(indexMutation(f, action)).rejects.toMatchObject({code: -32600, data: expect.stringContaining("cancellation")}); + f.current().rpc.resolve("turn/interrupt", {}); + await cancel; + f.current().rpc.notify({method: "turn/completed", params: {threadId: sessionId, turn: turn("queued-turn", "interrupted")}}); + }); + + it("keeps a cold archive reserved through its native ACK", async () => { + const f = await setup(false); + f.answers.set("thread/archive", () => undefined); + const archive = f.agent.sessionIndex.setArchived({sessionId}, true); + await vi.waitFor(() => expect(f.current().rpc.hasPending("thread/archive")).toBe(true)); + await expect(f.agent.manageSessionQueue(add)).rejects.toMatchObject({code: -32600, data: "Session is busy"}); + expect(writes(f, "add")).toEqual([]); + f.current().rpc.resolve("thread/archive", {}); + await archive; + }); +}); diff --git a/src/__tests__/CodexACPAgent/session-rewind-lifecycle.test.ts b/src/__tests__/CodexACPAgent/session-rewind-lifecycle.test.ts new file mode 100644 index 000000000..f56e4f9af --- /dev/null +++ b/src/__tests__/CodexACPAgent/session-rewind-lifecycle.test.ts @@ -0,0 +1,239 @@ +import {createHash} from "node:crypto"; +import {describe, expect, it, vi} from "vitest"; +import {createCodexMockTestFixture, createTestSessionState, mockPromptTurn} from "../acp-test-utils"; +import {SESSION_REWIND_METHOD} from "../../SessionRewind"; +import {SESSION_STEERING_METHOD} from "../../AcpExtensions"; +import {OPENAI_PROVIDER_ID} from "../../CodexAcpClient"; +import type {CodexAcpServer, SessionState} from "../../CodexAcpServer"; + +const sessionId = "session-id"; +const request = {sessionId, beforeMessage:{messageId:"user",messageFingerprint:`sha256:${createHash("sha256").update("original").digest("hex")}`,messageOccurrence:1}}; +const steer = {sessionId,prompt:[{type:"text",text:"followup"}],_meta:{steering:{idleBehavior:"promptRequired"}}}; +function deferred() {let resolve!: (value:T)=>void; const promise=new Promise(r=>{resolve=r;});return {promise,resolve};} +function setup() { + const fixture=createCodexMockTestFixture(); + const agent=fixture.getCodexAcpAgent(); + const state=createTestSessionState(); + (agent as unknown as {sessions:Map}).sessions.set(sessionId,state); + return {fixture,agent,state,client:fixture.getCodexAcpClient(),native:fixture.getCodexAppServerClient()}; +} +function rewind(agent:CodexAcpServer) {return agent.extMethod(SESSION_REWIND_METHOD,request);} + +describe("rewind lifecycle",()=>{ + it("rejects competing operations while rewinding and releases the fence on validation failure",async()=>{ + const {agent,client}=setup(); const gate=deferred<{rewound:boolean}>(); + const call=vi.spyOn(client,"rewindSession").mockReturnValue(gate.promise); + const pending=rewind(agent); + await vi.waitFor(()=>expect(call).toHaveBeenCalledOnce()); + for(const attempt of [ + ()=>rewind(agent),()=>agent.closeSession({sessionId}), + ()=>agent.resumeSession({sessionId,cwd:"/test/cwd",mcpServers:[]}), + ()=>agent.loadSession({sessionId,cwd:"/test/cwd",mcpServers:[]}), + ()=>agent.prompt({sessionId,prompt:[]}), + ()=>agent.extMethod(SESSION_STEERING_METHOD,steer), + ()=>agent.setSessionMode({sessionId,modeId:"read-only"}), + ()=>agent.setSessionConfigOption({sessionId,configId:"model",value:"m"}), + ()=>agent.readSessionRuntime({sessionId,resource:"context"}), + ()=>agent.manageSessionQueue({sessionId,action:"list"}), + ]) await expect(attempt()).rejects.toThrow(); + gate.resolve({rewound:true});await pending; + call.mockRejectedValueOnce(new Error("invalid target")); + await expect(rewind(agent)).rejects.toThrow("invalid target"); + call.mockResolvedValue({rewound:true}); + await expect(rewind(agent)).resolves.toEqual({rewound:true}); + }); + it("does not let rewind overtake an earlier settings change",async()=>{ + const {agent,client}=setup();const gate=deferred(); + const settings=vi.spyOn(client,"setCollaborationMode").mockReturnValue(gate.promise); + const pending=agent.setSessionConfigOption({sessionId,configId:"collaboration_mode",value:"plan"}); + await vi.waitFor(()=>expect(settings).toHaveBeenCalledOnce()); + const call=vi.spyOn(client,"rewindSession"); + await expect(rewind(agent)).rejects.toMatchObject({data:expect.stringContaining("update is in progress")}); + expect(call).not.toHaveBeenCalled();gate.resolve();await pending; + }); + it("blocks a later provider replacement until rewind has finished",async()=>{ + const replacement=createCodexMockTestFixture().getCodexAcpClient(); + vi.spyOn(replacement,"initialize").mockResolvedValue(); + vi.spyOn(replacement,"resumeSession").mockResolvedValue({} as never); + const restart=vi.fn().mockResolvedValue(replacement); + const fixture=createCodexMockTestFixture(restart);const agent=fixture.getCodexAcpAgent(); + await agent.initialize({protocolVersion:1}); + (agent as unknown as {sessions:Map}).sessions.set(sessionId,createTestSessionState()); + const gate=deferred<{rewound:boolean}>(); + const call=vi.spyOn(fixture.getCodexAcpClient(),"rewindSession").mockReturnValue(gate.promise); + const pending=rewind(agent);await vi.waitFor(()=>expect(call).toHaveBeenCalledOnce()); + const update=agent.setProvider({providerId:OPENAI_PROVIDER_ID,apiType:"openai",baseUrl:"https://example.test/v1"}); + await Promise.resolve();expect(restart).not.toHaveBeenCalled(); + gate.resolve({rewound:true});await pending;await update;expect(restart).toHaveBeenCalledOnce(); + }); + it("requires a successful load after a dispatched rewind has an unknown outcome",async()=>{ + const {agent,client,fixture}=setup(); + const call=vi.spyOn(client,"rewindSession").mockImplementation(async(_request,hooks)=>{hooks?.mutationStarted?.();throw Error("reload failed");}); + await expect(rewind(agent)).rejects.toThrow("reload failed"); + await expect(agent.prompt({sessionId,prompt:[]})).rejects.toMatchObject({data:expect.stringContaining("outcome is uncertain")}); + await expect(agent.extMethod(SESSION_STEERING_METHOD,steer)).rejects.toMatchObject({data:expect.stringContaining("outcome is uncertain")}); + await expect(rewind(agent)).rejects.toMatchObject({data:expect.stringContaining("outcome is uncertain")}); + vi.spyOn(agent as unknown as {loadSessionDuringOperation:()=>Promise<{}>},"loadSessionDuringOperation").mockRejectedValueOnce(Error("read failed")).mockResolvedValue({}); + await expect(agent.loadSession({sessionId,cwd:"/test/cwd",mcpServers:[]})).rejects.toThrow("read failed"); + await expect(rewind(agent)).rejects.toMatchObject({data:expect.stringContaining("outcome is uncertain")}); + await agent.loadSession({sessionId,cwd:"/test/cwd",mcpServers:[]}); + call.mockResolvedValue({rewound:true});await rewind(agent); + mockPromptTurn(fixture,sessionId); + await expect(agent.prompt({sessionId,prompt:[{type:"text",text:"continue"}]})).resolves.toMatchObject({stopReason:"end_turn"}); + }); + it("rejects rewind in the turn-start registration gap without releasing an unknown writer",async()=>{ + const {agent,fixture,native,client}=setup();const started=deferred(); + const turnStart=vi.spyOn(native,"turnStart").mockReturnValue(started.promise); + const pending=agent.prompt({sessionId,prompt:[{type:"text",text:"starting"}]}); + await vi.waitFor(()=>expect(turnStart).toHaveBeenCalledOnce()); + const call=vi.spyOn(client,"rewindSession"); + await expect(rewind(agent)).rejects.toMatchObject({data:expect.stringContaining("startup is pending")});expect(call).not.toHaveBeenCalled(); + await agent.closeSession({sessionId});await expect(pending).resolves.toMatchObject({stopReason:"cancelled"}); + started.resolve({turn:{id:"late",items:[],itemsView:"notLoaded",status:"interrupted",error:null,startedAt:null,completedAt:null,durationMs:null}} as never); + await vi.waitFor(()=>expect(fixture.getCodexConnectionEvents([]).some(e=>e.eventType==="request"&&e.method==="turn/interrupt")).toBe(true)); + }); +}); + +describe("opt-in steering",()=>{ + it("returns promptRequired while idle without calling turn/start",async()=>{ + const {agent,native}=setup();const start=vi.spyOn(native,"turnStart"); + await expect(agent.extMethod(SESSION_STEERING_METHOD,steer)).resolves.toEqual({outcome:"promptRequired"}); + expect(start).not.toHaveBeenCalled(); + }); + it("returns promptRequired if the native turn ends before steer is accepted",async()=>{ + const {agent,native,state}=setup();state.currentTurnId="active"; + const gate=deferred();const call=vi.spyOn(native,"turnSteer").mockReturnValue(gate.promise); + const pending=agent.extMethod(SESSION_STEERING_METHOD,steer); + await vi.waitFor(()=>expect(call).toHaveBeenCalledOnce()); + state.currentTurnId=null;gate.resolve(Promise.reject(Error("no active turn to steer")) as never); + await expect(pending).resolves.toEqual({outcome:"promptRequired"}); + }); + it("blocks rewind while a steer is already in flight",async()=>{ + const {agent,native,client,state}=setup();state.currentTurnId="active"; + const gate=deferred();const call=vi.spyOn(native,"turnSteer").mockReturnValue(gate.promise); + const pending=agent.extMethod(SESSION_STEERING_METHOD,steer); + await vi.waitFor(()=>expect(call).toHaveBeenCalledOnce()); + const revert=vi.spyOn(client,"rewindSession");await expect(rewind(agent)).rejects.toMatchObject({data:expect.stringContaining("update is in progress")}); + expect(revert).not.toHaveBeenCalled();gate.resolve({} as never);await expect(pending).resolves.toEqual({outcome:"injected"}); + }); +}); + +describe("runtime lifecycle",()=>{ + it("requires provider idle for MCP reconnect, and fences all sessions while reconnecting",async()=>{ + const {agent,client}=setup();const gate=deferred();const reload=vi.spyOn(client,"reloadMcpServers").mockReturnValue(gate.promise); + const pending=agent.controlSessionRuntime({sessionId,action:"reconnectMcp"});await vi.waitFor(()=>expect(reload).toHaveBeenCalledOnce()); + await expect(agent.prompt({sessionId,prompt:[]})).rejects.toMatchObject({data:expect.stringContaining("Runtime control")}); + await expect(rewind(agent)).rejects.toMatchObject({data:expect.stringContaining("Runtime control")}); + await expect(agent.closeSession({sessionId})).rejects.toMatchObject({data:expect.stringContaining("Runtime control")}); + await expect(agent.setProvider({providerId:OPENAI_PROVIDER_ID,apiType:"openai",baseUrl:"https://example.test"})).rejects.toMatchObject({data:expect.stringContaining("Runtime control")}); + gate.resolve();await expect(pending).resolves.toMatchObject({status:"ok",data:{scope:"provider",connectionsReady:false}}); + }); + it("rejects reconnect while a prompt is active",async()=>{ + const {agent,fixture,client,native}=setup();mockPromptTurn(fixture,sessionId);const finish=deferred(); + vi.spyOn(native,"awaitTurnCompleted").mockReturnValue(finish.promise); + const pending=agent.prompt({sessionId,prompt:[{type:"text",text:"running"}]}); + await vi.waitFor(()=>expect(agent.getSessionState(sessionId).currentTurnId).toBe("turn-id")); + const reload=vi.spyOn(client,"reloadMcpServers");await expect(agent.controlSessionRuntime({sessionId,action:"reconnectMcp"})).rejects.toMatchObject({data:expect.stringContaining("Provider is busy")});expect(reload).not.toHaveBeenCalled(); + await agent.closeSession({sessionId});await pending; + }); + it("republishes available commands after reloadSkills and releases the control fence",async()=>{ + const {agent,client}=setup();const skills=vi.spyOn(client,"listSkills").mockResolvedValue({data:[]}); + const publish=vi.spyOn(agent as unknown as {publishAvailableCommands:()=>Promise},"publishAvailableCommands").mockResolvedValue(); + await agent.controlSessionRuntime({sessionId,action:"reloadSkills"});expect(skills).toHaveBeenCalledWith({cwds:["/test/cwd"],forceReload:true});expect(publish).toHaveBeenCalledOnce(); + await expect(agent.extMethod(SESSION_STEERING_METHOD,steer)).resolves.toEqual({outcome:"promptRequired"}); + }); +}); + + +describe("native pending queue admission", () => { + it.each(["load", "resume"] as const)("does not reopen after close during %s queue preflight", async method => { + const {agent,client,native} = setup(); + client.queueSupport = {nativeVersion:"0.160.1",actions:["list"]}; + const pending = deferred<{data:[],nextCursor:null}>(); + const list = vi.fn().mockReturnValue(pending.promise); + vi.spyOn(native,"queueNative").mockReturnValue({list} as never); + const resume = vi.spyOn(client,"resumeSession"); + const load = vi.spyOn(client,"loadSession"); + const opening = method === "load" ? agent.loadSession({sessionId,cwd:"/test/cwd",mcpServers:[]}) : agent.resumeSession({sessionId,cwd:"/test/cwd",mcpServers:[]}); + const rejection = expect(opening).rejects.toMatchObject({data:expect.stringContaining("changed while checking")}); + await vi.waitFor(()=>expect(list).toHaveBeenCalledOnce()); + await agent.closeSession({sessionId}); + pending.resolve({data:[],nextCursor:null}); + await rejection; + expect(resume).not.toHaveBeenCalled();expect(load).not.toHaveBeenCalled(); + }); + it("does not read or resume a session while close already holds its fence", async () => { + const {agent,client,native} = setup(); + client.queueSupport={nativeVersion:"0.160.1",actions:["list"]}; + const release=deferred();vi.spyOn(client,"closeSession").mockReturnValue(release.promise); + const closing=agent.closeSession({sessionId}); + const list=vi.fn();vi.spyOn(native,"queueNative").mockReturnValue({list} as never); + await expect(agent.resumeSession({sessionId,cwd:"/test/cwd",mcpServers:[]})).rejects.toMatchObject({data:expect.stringContaining("closing")}); + expect(list).not.toHaveBeenCalled();release.resolve();await closing; + }); + it("rejects persisted pending work before native resume can auto-dispatch", async () => { + const {agent,client,native}=setup();client.queueSupport={nativeVersion:"0.160.1",actions:["list"]}; + vi.spyOn(native,"queueNative").mockReturnValue({list:vi.fn().mockResolvedValue({data:[{id:"queued"}],nextCursor:null})} as never); + const resume=vi.spyOn(client,"resumeSession"); + await expect(agent.resumeSession({sessionId,cwd:"/test/cwd",mcpServers:[]})).rejects.toMatchObject({data:expect.stringContaining("Pending native queue")}); + expect(resume).not.toHaveBeenCalled(); + }); +}); + + +describe("provider control recovery", () => { + it("bounds file-revert recovery and prevents late readiness from reaching file preflight", async () => { + vi.useFakeTimers(); + try { + const {agent,native}=setup(); + const ready=deferred(); + vi.spyOn(agent as unknown as {ensureSessionReady:()=>Promise},"ensureSessionReady").mockReturnValue(ready.promise); + const loaded=vi.spyOn(native,"threadLoadedList"); + const history=vi.spyOn(native,"threadReadWithHistory"); + const pending=agent.revertFiles({sessionId,toolCallId:"file",dryRun:true}); + const failure=expect(pending).rejects.toThrow("timed out"); + await vi.advanceTimersByTimeAsync(30_000); + await failure; + await expect(agent.closeSession({sessionId})).resolves.toEqual({}); + await expect(agent.newSession({cwd:"/test/cwd",mcpServers:[]})).rejects.toMatchObject({data:expect.stringContaining("invalidated")}); + ready.resolve(); + await vi.advanceTimersByTimeAsync(0); + expect(loaded).not.toHaveBeenCalled(); + expect(history).not.toHaveBeenCalled(); + } finally {vi.useRealTimers();} + }); + + it("invalidates sessions after a hung control, keeps close reachable and rejects late installation", async () => { + vi.useFakeTimers(); + try { + const {agent,client}=setup(); + const native = deferred(); + vi.spyOn(client,"reloadMcpServers").mockReturnValue(native.promise); + const pending=agent.controlSessionRuntime({sessionId,action:"reconnectMcp"}); + const failure=expect(pending).rejects.toThrow("timed out"); + await vi.advanceTimersByTimeAsync(30_000); + await failure; + await expect(agent.closeSession({sessionId})).resolves.toEqual({}); + await expect(agent.newSession({cwd:"/test/cwd",mcpServers:[]})).rejects.toMatchObject({data:expect.stringContaining("invalidated")}); + await expect(agent.setProvider({providerId:OPENAI_PROVIDER_ID,apiType:"openai",baseUrl:"https://example.test"})).rejects.toMatchObject({data:expect.stringContaining("invalidated")}); + native.resolve();await Promise.resolve(); + expect(()=>agent.getSessionState(sessionId)).toThrow(); + } finally { vi.useRealTimers(); } + }); + it("reserves creation before an asynchronous new session can install", async () => { + const {agent,client,state}=setup(); + const created=deferred(); + const open=vi.spyOn(agent as unknown as {getOrCreateSession:()=>Promise},"getOrCreateSession").mockReturnValue(created.promise); + const pending=agent.newSession({cwd:"/test/cwd",mcpServers:[]}); + await vi.waitFor(()=>expect(open).toHaveBeenCalledOnce()); + const reload=vi.spyOn(client,"reloadMcpServers"); + await expect(agent.controlSessionRuntime({sessionId,action:"reconnectMcp"})).rejects.toMatchObject({data:expect.stringContaining("Provider is busy")}); + await expect(agent.revertFiles({sessionId,toolCallId:"file",dryRun:true})).rejects.toMatchObject({data:expect.stringContaining("Provider is busy")}); + await expect(agent.setProvider({providerId:OPENAI_PROVIDER_ID,apiType:"openai",baseUrl:"https://example.test"})).rejects.toThrow(); + expect(reload).not.toHaveBeenCalled(); + created.resolve([sessionId,{currentModelId:state.currentModelId,availableModels:[]},{currentModeId:"agent",availableModes:[]}] as never); + await pending; + reload.mockResolvedValue(); + await expect(agent.controlSessionRuntime({sessionId,action:"reconnectMcp"})).resolves.toMatchObject({status:"ok"}); + }); +}); diff --git a/src/__tests__/CodexACPAgent/unmaterialized-thread.test.ts b/src/__tests__/CodexACPAgent/unmaterialized-thread.test.ts index 089a77c8a..1f6b60790 100644 --- a/src/__tests__/CodexACPAgent/unmaterialized-thread.test.ts +++ b/src/__tests__/CodexACPAgent/unmaterialized-thread.test.ts @@ -163,6 +163,7 @@ describe("session/load and a title generation left over from an earlier turn", ( }); agent.getSessionState(threadId).titleGen = { waitForIdle: () => titleGeneration, + retire: () => titleGeneration, markExistingTitle: () => {}, } as unknown as NonNullable["titleGen"]>; diff --git a/src/__tests__/CodexProcessTermination.failures.test.ts b/src/__tests__/CodexProcessTermination.failures.test.ts new file mode 100644 index 000000000..0196076d4 --- /dev/null +++ b/src/__tests__/CodexProcessTermination.failures.test.ts @@ -0,0 +1,152 @@ +import {EventEmitter} from "node:events"; +import {PassThrough} from "node:stream"; +import type {ChildProcessWithoutNullStreams} from "node:child_process"; +import {afterEach, describe, expect, it, vi} from "vitest"; +import {startCodexConnection, terminateCodexConnection} from "../CodexJsonRpcConnection"; + +const mocks = vi.hoisted(() => ({spawn: vi.fn(), spawnSync: vi.fn(), dispose: vi.fn()})); +vi.mock("node:child_process", () => ({spawn: mocks.spawn, spawnSync: mocks.spawnSync})); +vi.mock("cross-spawn", () => ({default: mocks.spawn})); +vi.mock("vscode-jsonrpc/node", () => ({ + createMessageConnection: () => ({listen() {}, onNotification() {}, dispose: mocks.dispose}), +})); +vi.mock("../StdUtils", () => ({createJSONRPCReader() { return {}; }, createJSONRPCWriter() {}})); +vi.mock("../Logger", () => ({logger: {log: vi.fn()}})); + +function child(pid: number | undefined = 987654) { + return Object.assign(new EventEmitter(), { + pid: pid as number | undefined, exitCode: null as number | null, signalCode: null as NodeJS.Signals | null, + stdin: new PassThrough(), stdout: new PassThrough(), stderr: new PassThrough(), + stdio: [null, null, null, Object.assign(new PassThrough(), {unref: vi.fn()})], kill: vi.fn(), + }); +} +const platform = Object.getOwnPropertyDescriptor(process, "platform")!; +function usePlatform(value: string) { + Object.defineProperty(process, "platform", {...platform, value}); +} +afterEach(() => { + Object.defineProperty(process, "platform", platform); + vi.restoreAllMocks(); + vi.useRealTimers(); + mocks.spawn.mockReset(); + mocks.dispose.mockReset(); +}); + +describe("CodexProcessTermination failure boundaries", () => { + it.each(["linux", "win32"])("does not signal or wait for a nonexistent PID on %s", async platform => { + usePlatform(platform); + const provider = child(); + provider.pid = undefined; + mocks.spawn.mockReturnValue(provider); + const native = startCodexConnection("synthetic-provider"); + await terminateCodexConnection(native); + await terminateCodexConnection(native); + expect(mocks.spawn).toHaveBeenCalledTimes(1); + expect(provider.kill).not.toHaveBeenCalled(); + expect(mocks.dispose).toHaveBeenCalledTimes(1); + }); + + it("rejects unowned processes without signalling a PID or shared group", async () => { + const provider = child(); + const kill = vi.spyOn(process, "kill"); + await expect(terminateCodexConnection({ + process: provider as unknown as ChildProcessWithoutNullStreams, + connection: {dispose: mocks.dispose} as any, + })).rejects.toThrow("unowned"); + expect(kill).not.toHaveBeenCalled(); + expect(provider.kill).not.toHaveBeenCalled(); + expect(mocks.spawn).not.toHaveBeenCalled(); + }); + + it.each(["SIGTERM", "SIGKILL"] as const)("recognizes already signalled Windows exit (%s) without reusing its PID", async signal => { + usePlatform("win32"); + const provider = child(); + mocks.spawn.mockReturnValue(provider); + const native = startCodexConnection("synthetic-provider"); + provider.signalCode = signal; + await expect(terminateCodexConnection(native)).rejects.toThrow("tree state is unknown"); + expect(mocks.spawn).toHaveBeenCalledTimes(1); + }); + + it("does not treat launcher exit as successful taskkill and never retries the old PID", async () => { + usePlatform("win32"); + const provider = child(); + const killer = child(987655); + mocks.spawn.mockReturnValueOnce(provider).mockReturnValueOnce(killer); + const native = startCodexConnection("synthetic-provider"); + const termination = terminateCodexConnection(native); + const failure = expect(termination).rejects.toThrow("tree termination failed"); + provider.signalCode = "SIGKILL"; + killer.emit("exit", 1); + await failure; + expect(terminateCodexConnection(native)).toBe(termination); + expect(mocks.spawn).toHaveBeenCalledTimes(2); + expect(mocks.spawn.mock.calls[1]).toEqual([ + "taskkill", ["/PID", "987654", "/T", "/F"], {windowsHide: true, stdio: "ignore"}, + ]); + }); + + it("rejects a taskkill timeout and caches the failure", async () => { + vi.useFakeTimers(); + usePlatform("win32"); + const provider = child(); + const killer = child(987655); + mocks.spawn.mockReturnValueOnce(provider).mockReturnValueOnce(killer); + const native = startCodexConnection("synthetic-provider"); + const termination = terminateCodexConnection(native); + const failure = expect(termination).rejects.toThrow("timed out"); + await vi.advanceTimersByTimeAsync(5_000); + await failure; + expect(killer.kill).toHaveBeenCalledTimes(1); + expect(terminateCodexConnection(native)).toBe(termination); + expect(mocks.spawn).toHaveBeenCalledTimes(2); + }); + + it("does not report stopped when taskkill succeeded but no child exit was observed", async () => { + vi.useFakeTimers(); + usePlatform("win32"); + mocks.spawn.mockReturnValueOnce(child()).mockReturnValueOnce(child(987655)); + const native = startCodexConnection("synthetic-provider"); + const termination = terminateCodexConnection(native); + const failure = expect(termination).rejects.toThrow("timed out"); + mocks.spawn.mock.results[1]!.value.emit("exit", 0); + await vi.advanceTimersByTimeAsync(5_000); + await failure; + }); + + it.each([undefined, "synthetic-provider"])("owns an isolated POSIX group for launcher %s and only probes its PGID", async command => { + usePlatform("linux"); + const provider = child(); + mocks.spawn.mockReturnValue(provider); + const kill = vi.spyOn(process, "kill").mockImplementation(() => { + throw Object.assign(new Error("gone"), {code: "ESRCH"}); + }); + const native = startCodexConnection(command); + expect(mocks.spawn.mock.calls[0]![2]).toMatchObject({detached: true, stdio: ["pipe", "pipe", "pipe", "pipe"]}); + expect((provider.stdio[3] as PassThrough & {unref: ReturnType}).unref).toHaveBeenCalledOnce(); + const args = mocks.spawn.mock.calls[0]![1] as string[]; + if (command === undefined) expect(args.some(arg => /codex[/\\]bin[/\\]codex.js$/.test(arg))).toBe(true); + else expect(args).toContain(command); + provider.signalCode = "SIGKILL"; + await terminateCodexConnection(native); + expect(kill).toHaveBeenCalledExactlyOnceWith(-987654, 0); + expect(provider.kill).not.toHaveBeenCalled(); + }); + + it("rejects when a POSIX group remains after its leader exits; no destructive PID retry", async () => { + vi.useFakeTimers(); + usePlatform("linux"); + const provider = child(); + mocks.spawn.mockReturnValue(provider); + const kill = vi.spyOn(process, "kill").mockReturnValue(true); + const native = startCodexConnection("synthetic-provider"); + provider.exitCode = 0; + const termination = terminateCodexConnection(native); + const failure = expect(termination).rejects.toThrow("timed out"); + await vi.advanceTimersByTimeAsync(5_000); + await failure; + expect(kill.mock.calls.every(([pid, signal]) => pid === -987654 && signal === 0)).toBe(true); + expect(terminateCodexConnection(native)).toBe(termination); + expect(provider.listenerCount("exit")).toBe(2); // only the permanent logging/disposal listeners + }); +}); diff --git a/src/__tests__/CodexProcessTermination.test.ts b/src/__tests__/CodexProcessTermination.test.ts new file mode 100644 index 000000000..8b818bfa5 --- /dev/null +++ b/src/__tests__/CodexProcessTermination.test.ts @@ -0,0 +1,176 @@ +import {mkdtempSync, existsSync, readFileSync, rmSync, writeFileSync} from "node:fs"; +import {tmpdir} from "node:os"; +import path from "node:path"; +import {fileURLToPath} from "node:url"; +import {once} from "node:events"; +import {afterEach, describe, expect, it, vi} from "vitest"; +import {startCodexConnection, terminateCodexConnection, type CodexConnection} from "../CodexJsonRpcConnection"; + +vi.mock("../Logger", () => ({logger: {log: vi.fn(), error: vi.fn()}})); +// Exercise the default launcher branch without launching the installed Codex. +vi.mock("node:module", async importOriginal => { + const original = await importOriginal(); + return {...original, createRequire: (...args: Parameters) => { + const require = original.createRequire(...args); + const resolve = require.resolve; + require.resolve = Object.assign((id: string, options?: Parameters[1]) => + id === "@openai/codex/bin/codex.js" + ? fileURLToPath(new URL("./fixtures/CodexProcessTermination.cjs", import.meta.url)) + : resolve(id, options), {paths: resolve.paths}); + return require; + }}; +}); + +const fixture = fileURLToPath(new URL("./fixtures/CodexProcessTermination.cjs", import.meta.url)); +const fixtures: {directory: string; native: CodexConnection}[] = []; +const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +async function until(check: () => boolean, timeout = 10_000): Promise { + const deadline = Date.now() + timeout; + while (!check()) { + if (Date.now() > deadline) throw new Error("Fixture condition timed out"); + await delay(20); + } +} + +function alive(pid: number): boolean { + try { + process.kill(pid, 0); + // Zombies have exited, though kill(0) still sees them until the OS reaps them. + if (process.platform === "linux") { + const stat = readFileSync(`/proc/${pid}/stat`, "utf8"); + return stat.slice(stat.lastIndexOf(")") + 2, stat.lastIndexOf(")") + 3) !== "Z"; + } + return true; + } catch (error) { + if (["ESRCH", "ENOENT"].includes((error as NodeJS.ErrnoException).code ?? "")) return false; + throw error; + } +} + +async function startFixture(defaultLauncher = false) { + const directory = mkdtempSync(path.join(tmpdir(), "CodexProcessTermination-")); + const native = startCodexConnection(defaultLauncher ? undefined : process.execPath, + {...process.env, APP_SERVER_LOGS: ""}, defaultLauncher ? ["parent", directory] : [fixture, "parent", directory]); + fixtures.push({directory, native}); + await until(() => existsSync(path.join(directory, "child.pid")) && existsSync(path.join(directory, "heartbeat"))); + const parentPid = Number(readFileSync(path.join(directory, "parent.pid"), "utf8")); + const childPid = Number(readFileSync(path.join(directory, "child.pid"), "utf8")); + return {directory, native, parentPid, childPid}; +} + +afterEach(async () => { + for (const {directory, native} of fixtures.splice(0)) { + // Cleanup through a fixture-specific file, never by a potentially stale PID. + writeFileSync(path.join(directory, "cleanup"), "stop"); + await terminateCodexConnection(native).catch(() => {}); + await delay(150); + rmSync(directory, {recursive: true, force: true}); + } +}); + +describe("CodexProcessTermination real owned processes", () => { + for (const mode of ["eof", "signal"]) { + it.skipIf(mode === "signal" && process.platform === "win32")(`preserves provider stdio and exit status through ${mode}`, async () => { + const directory = mkdtempSync(path.join(tmpdir(), "CodexProcessTermination-")); + const native = startCodexConnection(process.execPath, {...process.env, APP_SERVER_LOGS: ""}, + [fixture, "stdio", directory]); + fixtures.push({directory, native}); + let stdout = ""; + let stderr = ""; + native.process.stdout.on("data", data => {stdout += data;}); + native.process.stderr.on("data", data => {stderr += data;}); + await until(() => existsSync(path.join(directory, "ready"))); + const line = '{"method":"fixture/echo","params":{"text":"你好"}}\n'; + native.process.stdin.write(line); + await until(() => stdout === line); + const closed = once(native.process, "close"); + if (mode === "eof") native.process.stdin.end(); + else native.process.kill("SIGTERM"); + await closed; + expect(native.process.exitCode).toBe(mode === "eof" ? 17 : 29); + expect(stderr).toBe(mode === "eof" ? "fixture EOF\n" : "fixture SIGTERM\n"); + if (process.platform !== "win32") await terminateCodexConnection(native); + }, 15_000); + } + + it.skipIf(process.platform !== "win32")("does not mistake a failed Windows shim launch for a verified stopped tree", async () => { + const directory = mkdtempSync(path.join(tmpdir(), "CodexProcessTermination-")); + const native = startCodexConnection(path.join(directory, "missing-provider.exe")); + fixtures.push({directory, native}); + const [error] = await once(native.process, "error"); + expect(error.code).toBe("ENOENT"); + await until(() => native.process.exitCode !== null || native.process.signalCode !== null); + const termination = terminateCodexConnection(native); + await expect(termination).rejects.toThrow("tree state is unknown"); + expect(terminateCodexConnection(native)).toBe(termination); + }); + + it.skipIf(process.platform === "win32")("reports a provider spawn failure on stderr and exits without holding pipes open", async () => { + const directory = mkdtempSync(path.join(tmpdir(), "CodexProcessTermination-")); + const native = startCodexConnection(path.join(directory, "missing-provider")); + fixtures.push({directory, native}); + let stderr = ""; + native.process.stderr.on("data", data => {stderr += data;}); + await once(native.process, "close"); + expect(native.process.exitCode).toBe(1); + expect(stderr).toContain("ENOENT"); + await terminateCodexConnection(native); + }, 15_000); + + it("stops the default Node launcher and its native-shaped child", async () => { + const target = await startFixture(true); + await terminateCodexConnection(target.native); + await until(() => !alive(target.parentPid) && !alive(target.childPid)); + }, 20_000); + + it("stops both an ignoring parent and child, leaves another tree alive, and shares repeated closes", async () => { + const target = await startFixture(); + const unrelated = await startFixture(); + expect(alive(target.parentPid)).toBe(true); + expect(alive(target.childPid)).toBe(true); + if (process.platform !== "win32") { + process.kill(target.parentPid, "SIGTERM"); + process.kill(target.childPid, "SIGTERM"); + await delay(100); + expect(alive(target.parentPid)).toBe(true); + expect(alive(target.childPid)).toBe(true); + } + const first = terminateCodexConnection(target.native); + expect(terminateCodexConnection(target.native)).toBe(first); + await first; + await until(() => !alive(target.parentPid) && !alive(target.childPid)); + expect(alive(unrelated.parentPid)).toBe(true); + expect(alive(unrelated.childPid)).toBe(true); + await expect(terminateCodexConnection(target.native)).resolves.toBeUndefined(); + await terminateCodexConnection(unrelated.native); + }, 25_000); + + it.each(["exit-parent", "signal-parent"])("handles %s before close without killing a stale launcher PID", async mode => { + const target = await startFixture(); + writeFileSync(path.join(target.directory, mode), "stop"); + await until(() => target.native.process.exitCode !== null || target.native.process.signalCode !== null); + if (process.platform === "win32") { + // taskkill cannot safely reconstruct a tree once its launcher has exited. + await expect(terminateCodexConnection(target.native)).rejects.toThrow("tree state is unknown"); + } else { + if (mode === "signal-parent") { + expect(target.native.process.exitCode).toBeNull(); + expect(target.native.process.signalCode).toBe("SIGKILL"); + } else { + expect(target.native.process.exitCode).toBe(23); + } + await terminateCodexConnection(target.native); + await until(() => !alive(target.childPid)); + } + }, 20_000); + + it.skipIf(process.platform === "win32")("cleans descendants even when the supervisor is killed first", async () => { + const target = await startFixture(); + target.native.process.kill("SIGKILL"); + await until(() => target.native.process.signalCode !== null); + await terminateCodexConnection(target.native); + expect(alive(target.parentPid)).toBe(false); + expect(alive(target.childPid)).toBe(false); + }, 20_000); +}); diff --git a/src/__tests__/SessionDiscovery.test.ts b/src/__tests__/SessionDiscovery.test.ts new file mode 100644 index 000000000..1d94faae8 --- /dev/null +++ b/src/__tests__/SessionDiscovery.test.ts @@ -0,0 +1,31 @@ +import {describe, expect, it, vi} from "vitest"; +import {searchSessions, sessionAttachments, sessionAttachmentsParser, sessionSearchParser, type SessionDiscoveryClient} from "../SessionDiscovery"; +import type {Thread} from "../app-server/v2"; + +describe("native session discovery", () => { + it("rejects invalid search ranges and arbitrary native parameters", () => { + for (const p of [{searchTerm: " "}, {searchTerm: "x", limit: 0}, {searchTerm: "x", limit: 101}, {searchTerm: "x", method: "thread/delete"}]) { + expect(sessionSearchParser.safeParse(p).success).toBe(false); + } + }); + it("preserves the native cursor and snippet without leaking runtime config", async () => { + const native = {threadSearch: vi.fn(async () => ({data: [{thread: {id: "s", cwd: "/work", name: "name", updatedAt: 42, modelProvider: "private-config"} as Thread, snippet: "match"}], nextCursor: "opaque"}))}; + expect(await searchSessions(native, {searchTerm: "match", archived: true, limit: 20})).toEqual({version: 1, sessions: [{sessionId: "s", cwd: "/work", title: "name", updatedAt: 42, snippet: "match"}], nextCursor: "opaque"}); + expect(native.threadSearch).toHaveBeenCalledWith({searchTerm: "match", archived: true, limit: 20}); + }); + it("bounds attachment metadata and rejects extra destinations", () => { + const base = {sessionId: "s", action: "add", attachmentType: "pull_request", identityKey: "https://example.test/pr/1"}; + expect(sessionAttachmentsParser.safeParse({...base, payload: {title: "PR"}}).success).toBe(true); + expect(sessionAttachmentsParser.safeParse({...base, payload: "x".repeat(256 * 1024)}).success).toBe(false); + expect(sessionAttachmentsParser.safeParse({...base, payload: {}, threadId: "other"}).success).toBe(false); + }); + it("uses the checked session identity for every attachment operation", async () => { + const add = vi.fn(async () => ({outcome: "created", attachment: {id: "a"}})); + const native = {threadAttachmentAdd: add} as unknown as SessionDiscoveryClient; + const request = {sessionId: "s", action: "add" as const, attachmentType: "pull_request", identityKey: "url", payload: {url: "https://example.test/pr/1"}}; + await expect(sessionAttachments(native, "other", request)).rejects.toThrow("Session mismatch"); + expect(add).not.toHaveBeenCalled(); + await sessionAttachments(native, "s", request); + expect(add).toHaveBeenCalledWith({threadId: "s", attachmentType: "pull_request", identityKey: "url", payload: request.payload}); + }); +}); diff --git a/src/__tests__/SessionFileRevert.test.ts b/src/__tests__/SessionFileRevert.test.ts new file mode 100644 index 000000000..50ec84f86 --- /dev/null +++ b/src/__tests__/SessionFileRevert.test.ts @@ -0,0 +1,175 @@ +import { execFileSync } from "node:child_process"; +import { mkdtemp, mkdir, writeFile, readFile, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import type { Thread, ThreadItem } from "../app-server/v2"; +import { revertSessionFiles } from "../SessionFileRevert"; + +const directories: string[] = []; +afterEach(async () => { + await Promise.all( + directories + .splice(0) + .map((dir) => rm(dir, { recursive: true, force: true })), + ); +}); +async function setup(changes?: unknown[]) { + const cwd = await mkdtemp(path.join(os.tmpdir(), "codex-file-revert-")); + directories.push(cwd); + execFileSync("git", ["init", "-q", cwd], { windowsHide: true }); + await writeFile(path.join(cwd, "changed.txt"), "old\n"); + execFileSync("git", ["add", "."], { cwd, windowsHide: true }); + const index = await readFile(path.join(cwd, ".git/index")); + await writeFile(path.join(cwd, "changed.txt"), "new\n"); + await writeFile(path.join(cwd, "user.txt"), "keep user's work\n"); + const item = { + type: "fileChange", + id: "tool", + status: "completed", + changes: changes ?? [ + { + path: path.join(cwd, "changed.txt"), + kind: { type: "update", move_path: null }, + diff: "@@ -1 +1 @@\n-old\n+new\n", + }, + ], + } as ThreadItem; + const readHistory = async () => ({ + thread: { id: "session", turns: [{ id: "turn", items: [item] }] } as Thread, + }); + return { + cwd, + index, + readHistory, + session: { sessionId: "session", cwd, additionalDirectories: [] }, + request: { sessionId: "session", toolCallId: "tool", dryRun: true }, + }; +} +describe("native file patch revert", () => { + it("previews then reverses the Git text patch while preserving the real index and unrelated edits", async () => { + const f = await setup(); + const preview = await revertSessionFiles( + f.request, + f.session, + f.readHistory, + ); + expect(preview.canRevert).toBe(true); + expect(await readFile(path.join(f.cwd, "changed.txt"), "utf8")).toBe( + "new\n", + ); + const applied = await revertSessionFiles( + { ...f.request, dryRun: false, previewToken: preview.previewToken }, + f.session, + f.readHistory, + ); + expect(applied.reverted).toBe(true); + expect( + (await readFile(path.join(f.cwd, "changed.txt"), "utf8")).replaceAll( + "\r\n", + "\n", + ), + ).toBe("old\n"); + expect(await readFile(path.join(f.cwd, "user.txt"), "utf8")).toBe("keep user's work\n"); + expect(await readFile(path.join(f.cwd, ".git/index"))).toEqual(f.index); + }); + it("refuses a changed file after preview and does not overwrite the user's new content", async () => { + const f = await setup(); + const preview = await revertSessionFiles( + f.request, + f.session, + f.readHistory, + ); + await writeFile(path.join(f.cwd, "changed.txt"), "new\nuser addition\n"); + expect( + await revertSessionFiles( + { ...f.request, dryRun: false, previewToken: preview.previewToken }, + f.session, + f.readHistory, + ), + ).toMatchObject({ reverted: false, reason: "stale_preview" }); + expect(await readFile(path.join(f.cwd, "changed.txt"), "utf8")).toBe( + "new\nuser addition\n", + ); + }); + it("rejects paths outside the session and Git metadata", async () => { + for (const file of ["../outside.txt", ".git/config"]) { + const f = await setup([ + { path: file, kind: { type: "add" }, diff: "x\n" }, + ]); + expect( + await revertSessionFiles(f.request, f.session, f.readHistory), + ).toMatchObject({ canRevert: false, reverted: false }); + } + }); + it("does not touch files without an explicit preview token", async () => { + const f = await setup(); + await expect( + revertSessionFiles( + { ...f.request, dryRun: false }, + f.session, + f.readHistory, + ), + ).rejects.toThrow(); + }); + it("undoes recorded create/delete patches in one batch", async () => { + const f = await setup([ + { path: "created.txt", kind: { type: "add" }, diff: "created\n" }, + { path: "deleted.txt", kind: { type: "delete" }, diff: "deleted\n" }, + ]); + await writeFile(path.join(f.cwd, "created.txt"), "created\n"); + const preview = await revertSessionFiles( + f.request, + f.session, + f.readHistory, + ); + expect(preview.canRevert).toBe(true); + expect( + await revertSessionFiles( + { ...f.request, dryRun: false, previewToken: preview.previewToken }, + f.session, + f.readHistory, + ), + ).toMatchObject({ reverted: true }); + await expect(readFile(path.join(f.cwd, "created.txt"))).rejects.toThrow(); + expect( + (await readFile(path.join(f.cwd, "deleted.txt"), "utf8")).replaceAll( + "\r\n", + "\n", + ), + ).toBe("deleted\n"); + }); + it("refuses a patch whose current content conflicts", async () => { + const f = await setup(); + await writeFile(path.join(f.cwd, "changed.txt"), "different\n"); + expect( + await revertSessionFiles(f.request, f.session, f.readHistory), + ).toMatchObject({ reason: "patch_conflict", canRevert: false }); + }); + it("refuses directory nodes as patch targets", async () => { + const f = await setup([ + { path: "folder", kind: { type: "add" }, diff: "x\n" }, + ]); + await mkdir(path.join(f.cwd, "folder")); + expect( + await revertSessionFiles(f.request, f.session, f.readHistory), + ).toMatchObject({ reason: "unsupported_file_type" }); + }); + it.each([{type:"futureKind"}, null, {}])("rejects an entire mixed batch with an unknown kind %j", async kind => { + const f = await setup([ + {path:"changed.txt",kind:{type:"update",move_path:null},diff:"@@ -1 +1 @@\n-old\n+new\n"}, + {path:"user.txt",kind,diff:"user content"}, + ]); + const result = await revertSessionFiles(f.request, f.session, f.readHistory); + expect(result).toMatchObject({canRevert:false,reverted:false,reason:"unsupported_patch"}); + expect(await readFile(path.join(f.cwd,"changed.txt"),"utf8")).toBe("new\n"); + expect(await readFile(path.join(f.cwd,"user.txt"),"utf8")).toBe("keep user's work\n"); + expect(await readFile(path.join(f.cwd,".git/index"))).toEqual(f.index); + }); + it("rejects a mixed batch containing a missing diff", async () => { + const f=await setup([{path:"changed.txt",kind:{type:"update",move_path:null},diff:"@@ -1 +1 @@\n-old\n+new\n"},{path:"user.txt",kind:{type:"add"}}]); + expect(await revertSessionFiles(f.request,f.session,f.readHistory)).toMatchObject({reverted:false,reason:"unsupported_patch"}); + expect(await readFile(path.join(f.cwd,"changed.txt"),"utf8")).toBe("new\n"); + }); + +}); diff --git a/src/__tests__/SessionQueue.test.ts b/src/__tests__/SessionQueue.test.ts new file mode 100644 index 000000000..4cece6d56 --- /dev/null +++ b/src/__tests__/SessionQueue.test.ts @@ -0,0 +1,202 @@ +import {describe, expect, it, vi} from "vitest"; +import type {Turn} from "../app-server/v2/Turn"; +import { + SESSION_QUEUE_ACTIONS, SESSION_QUEUE_METHOD, parseSessionQueueRequest, + probeSessionQueueSupport, runSessionQueue, sessionQueueCapability, + type NativeQueueInput, type SessionQueueNative, type SessionQueueSupport, +} from "../SessionQueue"; + +const input: NativeQueueInput[] = [ + {type: "text", text: "正文", text_elements: [{byteRange: {start: 0, end: 6}, placeholder: null}]}, + {type: "text", text: " keep whitespace "}, + {type: "image", fileId: "file-id", detail: "original"}, + {type: "image", url: "data:image/png;base64,AA==", detail: null}, + {type: "localImage", path: "C:\\a.png"}, + {type: "audio", url: "data:audio/wav;base64,AA=="}, + {type: "localAudio", path: "C:\\a.wav"}, + {type: "skill", name: "skill", path: "C:\\SKILL.md"}, + {type: "mention", name: "file", path: "C:\\file.txt"}, +]; +const support: SessionQueueSupport = {nativeVersion: "0.160.1", actions: SESSION_QUEUE_ACTIONS}; +const queued = {id: "native-q", input, clientUserMessageId: "client-id", futureNativeField: {keep: true}}; +const turn: Turn = {id: "native-turn", items: [], itemsView: "notLoaded", status: "inProgress", error: null, startedAt: null, completedAt: null, durationMs: null}; + +function fixture() { + const native = { + list: vi.fn().mockResolvedValue({data: [queued], nextCursor: "opaque-next"}), + add: vi.fn().mockResolvedValue({queuedSubmission: queued}), + update: vi.fn().mockResolvedValue({queuedSubmission: queued}), + delete: vi.fn().mockResolvedValue({deleted: false}), + reorder: vi.fn().mockResolvedValue({}), + start: vi.fn().mockResolvedValue({turn}), + }; + const dependencies = {sessionId: "session", support, native}; + return {native, dependencies, run: (request: unknown) => runSessionQueue(request, dependencies)}; +} + +describe("session queue capability", () => { + it.each(["0.154.0", "0.159.3", "0.160.0", "0.160.1-preview.1", "codex 0.160.1", "2.1.1-preview", "", null, undefined])( + "does not probe or advertise unknown/old/unverified native %s", async version => { + const probe = vi.fn(async () => true); + const result = await probeSessionQueueSupport(version, probe); + expect(probe).not.toHaveBeenCalled(); + expect(result.actions).toEqual([]); + expect(sessionQueueCapability(result)).toBeUndefined(); + }, + ); + it("advertises only each action positively confirmed on the running native", async () => { + const result = await probeSessionQueueSupport("0.160.1", async action => { + if (action === "start") throw new Error("probe transport failed"); + return action === "list"; + }); + expect(sessionQueueCapability(result)).toEqual({version: 1, method: SESSION_QUEUE_METHOD, actions: ["list"]}); + expect(Object.isFrozen(result.actions)).toBe(true); + }); + it.each(["0.160.1", "0.161.0", "1.0.0"])("still requires positive evidence for %s", async version => { + const result = await probeSessionQueueSupport(version, async () => false); + expect(sessionQueueCapability(result)).toBeUndefined(); + }); + it("rejects an old native support record even if its actions were supplied", async () => { + const f = fixture(); + f.dependencies.support = {nativeVersion: "0.154.0", actions: SESSION_QUEUE_ACTIONS}; + await expect(f.run({sessionId: "session", action: "start"})).resolves.toEqual({status: "unsupported"}); + expect(f.native.start).not.toHaveBeenCalled(); + }); +}); + +describe("session queue native boundary", () => { + it("keeps native multimodal input, omission, IDs, and unknown RESULT fields", async () => { + const f = fixture(); + const result = {queuedSubmission: queued, nativeExtra: {revision: "native-owned"}}; + f.native.add.mockResolvedValue(result); + await expect(f.run({sessionId: "session", action: "add", input, clientUserMessageId: "client-id"})) + .resolves.toEqual({status: "ok", result}); + expect(f.native.add).toHaveBeenCalledExactlyOnceWith({threadId: "session", input, clientUserMessageId: "client-id"}); + expect(f.native.add.mock.calls[0]![0].input[1]).not.toHaveProperty("text_elements"); + expect(result.queuedSubmission.id).toBe("native-q"); + }); + it("preserves list pagination, nulls, and the native result object", async () => { + const f = fixture(); + const result = {data: [queued], nextCursor: "opaque-next", total: 200}; + f.native.list.mockResolvedValue(result); + const response = await f.run({sessionId: "session", action: "list", cursor: "opaque-in", limit: 0}); + expect(response.status === "ok" && response.result).toBe(result); + expect(f.native.list).toHaveBeenLastCalledWith({threadId: "session", cursor: "opaque-in", limit: 0}); + await f.run({sessionId: "session", action: "list", cursor: null, limit: null}); + expect(f.native.list).toHaveBeenLastCalledWith({threadId: "session", cursor: null, limit: null}); + await f.run({sessionId: "session", action: "list"}); + expect(f.native.list).toHaveBeenLastCalledWith({threadId: "session"}); + }); + it("updates only the named native submission without replacing its client ID", async () => { + const f = fixture(); + await expect(f.run({sessionId: "session", action: "update", queuedSubmissionId: "q", input})) + .resolves.toEqual({status: "ok", result: {queuedSubmission: queued}}); + expect(f.native.update).toHaveBeenCalledExactlyOnceWith({threadId: "session", queuedSubmissionId: "q", input}); + }); + it("keeps delete=false and does not invent successful removal", async () => { + const f = fixture(); + await expect(f.run({sessionId: "session", action: "delete", queuedSubmissionId: "gone"})) + .resolves.toEqual({status: "ok", result: {deleted: false}}); + expect(f.native.delete).toHaveBeenCalledExactlyOnceWith({threadId: "session", queuedSubmissionId: "gone"}); + }); + it("delegates native ordering and membership validation without local reordering", async () => { + const f = fixture(); + const ids = ["third", "first"]; + await expect(f.run({sessionId: "session", action: "reorder", queuedSubmissionIds: ids})) + .resolves.toEqual({status: "ok", result: {}}); + expect(f.native.reorder).toHaveBeenCalledExactlyOnceWith({threadId: "session", queuedSubmissionIds: ids}); + expect(f.native.list).not.toHaveBeenCalled(); + }); + it.each([{}, {queuedSubmissionId: null}, {queuedSubmissionId: "q"}])("only fake-starts once with native selector %j", async selector => { + const f = fixture(); + await expect(f.run({sessionId: "session", action: "start", ...selector})) + .resolves.toEqual({status: "ok", result: {turn}}); + expect(f.native.start).toHaveBeenCalledExactlyOnceWith({threadId: "session", ...selector}); + expect(f.native.delete).not.toHaveBeenCalled(); + }); + it.each(SESSION_QUEUE_ACTIONS)("never accesses another session for %s", async action => { + const f = fixture(); + const requests = { + list: {}, add: {input, clientUserMessageId: "id"}, update: {input, queuedSubmissionId: "id"}, + delete: {queuedSubmissionId: "id"}, reorder: {queuedSubmissionIds: []}, start: {}, + }; + await expect(f.run({sessionId: "other", action, ...requests[action]})).rejects.toThrow("bound session"); + for (const callback of Object.values(f.native)) expect(callback).not.toHaveBeenCalled(); + }); + it("rejects unproven start even when list was positively probed", async () => { + const f = fixture(); + f.dependencies.support = await probeSessionQueueSupport("0.160.1", async action => action === "list"); + await expect(f.run({sessionId: "session", action: "start"})).resolves.toEqual({status: "unsupported"}); + expect(f.native.start).not.toHaveBeenCalled(); + }); + it("reports a definitive method-not-found without retry or fallback", async () => { + const f = fixture(); + f.native.start.mockRejectedValue({code: -32601, message: "Method not found"}); + await expect(f.run({sessionId: "session", action: "start"})).resolves.toEqual({status: "unsupported"}); + expect(f.native.start).toHaveBeenCalledTimes(1); + }); + it.each([ + {code: -32603, message: "storage failed"}, + new Error("response lost after native commit"), + ])("propagates failures without replaying an uncertain mutation", async error => { + const f = fixture(); + f.native.add.mockRejectedValue(error); + await expect(f.run({sessionId: "session", action: "add", input, clientUserMessageId: "client-id"})).rejects.toBe(error); + expect(f.native.add).toHaveBeenCalledTimes(1); + expect(f.native.list).not.toHaveBeenCalled(); + }); + it.each([-32600, -32602])("keeps a definitive native validation failure as ACP invalid params (%s)", async code => { + const f = fixture(); + f.native.reorder.mockRejectedValue({code, message: "queue reorder must include every queued submission exactly once"}); + await expect(f.run({sessionId: "session", action: "reorder", queuedSubmissionIds: ["stale"]})) + .rejects.toMatchObject({code: -32602, name: "RequestError"}); + expect(f.native.reorder).toHaveBeenCalledOnce(); + }); +}); + +describe("session queue input validation", () => { + it.each([ + null, [], "list", {}, + {sessionId: "session", action: "thread/delete"}, + {sessionId: "session", action: "list", threadId: "other"}, + {sessionId: "session", action: "list", method: "thread/delete"}, + {sessionId: "session", action: "list", params: {threadId: "other"}}, + {sessionId: "session", action: "list", limit: -1}, + {sessionId: "session", action: "list", limit: 1.5}, + {sessionId: "session", action: "list", limit: 0x1_0000_0000}, + {sessionId: "session", action: "list", cursor: 1}, + {sessionId: " ", action: "list"}, + {sessionId: "session\n", action: "list"}, + {sessionId: "session", action: "add", input}, + {sessionId: "session", action: "add", input, clientUserMessageId: "id", revision: 1}, + {sessionId: "session", action: "start", input}, + {sessionId: "session", action: "start", queuedSubmissionId: ""}, + {sessionId: "session", action: "reorder", queuedSubmissionIds: [null]}, + {sessionId: "session", action: "update", input, queuedSubmissionId: "q", clientUserMessageId: "replacement"}, + ])("rejects malformed/extra fields before any native call: %j", async request => { + const f = fixture(); + await expect(f.run(request)).rejects.toThrow("Invalid session queue request"); + for (const callback of Object.values(f.native)) expect(callback).not.toHaveBeenCalled(); + }); + it.each([ + {type: "text", text: 42}, + {type: "text", text: "x", text_elements: [{byteRange: {start: -1, end: 1}}]}, + {type: "text", text: "x", text_elements: [{byteRange: {start: 0, end: 1}, placeholder: 7}]}, + {type: "image", url: "data:x", fileId: "ambiguous"}, + {type: "image", url: "data:x", detail: "unlimited"}, + {type: "image", fileId: "x", threadId: "other"}, + {type: "toolOutput", text: "inject"}, + {type: "localImage", path: 42}, + ])("rejects unknown/malformed native input: %j", block => { + expect(() => parseSessionQueueRequest({sessionId: "session", action: "add", input: [block], clientUserMessageId: "id"})) + .toThrow("Invalid session queue request"); + }); + it("does not echo rejected prompt/URL contents in errors", () => { + try { + parseSessionQueueRequest({sessionId: "session", action: "add", input: [{type: "unknown", secret: "sensitive"}], clientUserMessageId: "id"}); + expect.fail("should reject"); + } catch (error) { + expect(String(error)).not.toContain("sensitive"); + } + }); +}); diff --git a/src/__tests__/SessionRewind.test.ts b/src/__tests__/SessionRewind.test.ts new file mode 100644 index 000000000..1228532da --- /dev/null +++ b/src/__tests__/SessionRewind.test.ts @@ -0,0 +1,138 @@ +import {createHash} from "node:crypto"; +import {describe, expect, it, vi} from "vitest"; +import {rewindSession, type SessionHistoryPoint} from "../SessionRewind"; +import {CodexAcpClient} from "../CodexAcpClient"; +import type {CodexAppServerClient} from "../CodexAppServerClient"; +import type {Thread, Turn, ThreadItem, UserInput} from "../app-server/v2"; +import {userInputVisibleText} from "../UserInputContent"; + +const hash = (text: string) => `sha256:${createHash("sha256").update(text).digest("hex")}`; +const point = (id: string, text: string, occurrence = 1): SessionHistoryPoint => ({messageId: id, messageFingerprint: hash(text), messageOccurrence: occurrence}); +function turn(id: string, text: string, items: ThreadItem[] = []): Turn { + return {id, items: [{type: "userMessage", id: `u-${id}`, clientId: null, content: [{type: "text", text, text_elements: []}]}, ...items], itemsView: "full", status: "completed", error: null, startedAt: null, completedAt: null, durationMs: null}; +} +function answer(id: string, text: string): ThreadItem { + return {type: "agentMessage", id, text, phase: null, memoryCitation: null, delivery: null, questions: null}; +} +function fixture(turns: Turn[], mode: "legacy" | "paginated" = "paginated") { + const thread = {id: "session", historyMode: mode, turns} as Thread; + const client = { + threadReadWithHistory: vi.fn(async () => ({thread: structuredClone(thread)})), + threadRevert: vi.fn(async ({beforeTurnId}: {beforeTurnId: string}) => { + thread.turns = thread.turns.slice(0, thread.turns.findIndex(t => t.id === beforeTurnId)); + return {thread: {...thread, turns: []}}; + }), + threadRollback: vi.fn(async ({numTurns}: {numTurns: number}) => { + thread.turns = thread.turns.slice(0, -numTurns); + return {thread}; + }), + }; + const run = (beforeMessage: SessionHistoryPoint, resumeAtMessage?: SessionHistoryPoint, hooks?: Parameters[2]) => + rewindSession({sessionId: "session", beforeMessage, ...(resumeAtMessage ? {resumeAtMessage} : {})}, client as unknown as CodexAppServerClient, hooks); + return {client, thread, run}; +} + +describe("session rewind", () => { + it.each([0, 1, 2])("keeps the same ID and precise prefix before turn %i", async index => { + const f = fixture([turn("1", "one"), turn("2", "two"), turn("3", "three")]); + await expect(f.run(point(`u-${index + 1}`, ["one", "two", "three"][index]!))).resolves.toEqual({rewound: true}); + expect(f.thread.id).toBe("session"); + expect(f.thread.turns).toHaveLength(index); + expect(f.client.threadRollback).not.toHaveBeenCalled(); + }); + it("supports repeated edits after the retained prefix is persisted", async () => { + const f = fixture([turn("1", "one"), turn("2", "two")]); + await f.run(point("u-2", "two")); + f.thread.turns.push(turn("3", "replacement")); + await f.run(point("u-3", "replacement")); + expect(f.thread.turns.map(t => t.id)).toEqual(["1"]); + }); + it("rejects stale content even when the exact ID still exists", async () => { + const f = fixture([turn("1", "new")]); + await expect(f.run(point("u-1", "old"))).rejects.toThrow("fingerprint changed"); + expect(f.client.threadRevert).not.toHaveBeenCalled(); + }); + it("prefers the exact segmented ID before its protocol ID fallback", async () => { + const a = turn("1", "fallback"), b = turn("2", "exact"); + b.items[0]!.id = "u-1:segment:0"; + const f = fixture([a, b]); + await f.run(point("u-1:segment:0", "exact")); + expect(f.thread.turns.map(t => t.id)).toEqual(["1"]); + }); + it("resolves a unique restored text fingerprint", async () => { + const f = fixture([turn("1", "one")]); + await f.run(point("stale-id", "one")); + expect(f.thread.turns).toEqual([]); + }); + it("requires a current retained boundary for repeated fingerprint fallback", async () => { + const f = fixture([turn("1", "repeat", [answer("a-1", "answer")]), turn("2", "repeat")]); + await expect(f.run(point("stale", "repeat", 2))).rejects.toThrow("retained boundary"); + expect(f.client.threadRevert).not.toHaveBeenCalled(); + await f.run(point("stale", "repeat", 2), point("a-1", "answer")); + expect(f.thread.turns.map(t => t.id)).toEqual(["1"]); + }); + it("rejects a stale retained boundary without mutating", async () => { + const f = fixture([turn("1", "one", [answer("a-1", "answer")]), turn("2", "two")]); + await expect(f.run(point("u-2", "two"), point("deleted-anchor", "answer"))).rejects.toThrow("boundary changed"); + expect(f.client.threadRevert).not.toHaveBeenCalled(); + }); + it("rejects midturn steering rather than discarding the containing turn", async () => { + const f = fixture([turn("1", "one", [answer("a-1", "working"), ...turn("steer", "steer").items])]); + await expect(f.run(point("u-steer", "steer"))).rejects.toThrow("does not start a turn"); + expect(f.client.threadRevert).not.toHaveBeenCalled(); + }); + it("shares current replay text for multimodal and skill inputs", async () => { + const f = fixture([turn("1", "look")]); + const content: UserInput[] = [{type:"text", text:"look", text_elements:[]}, {type:"image", url:"https://example.test/i.png"}, {type:"skill", name:"review", path:"/tmp/skill"}]; + (f.thread.turns[0]!.items[0] as Extract).content = content; + await f.run(point("stale", userInputVisibleText(content))); + expect(f.thread.turns).toEqual([]); + }); + it("requires exact identity when replay text omits a resource", async () => { + const f = fixture([turn("1", "")]); + (f.thread.turns[0]!.items[0] as Extract).content = [{type:"mention", name:"file", path:"/tmp/file"}]; + await expect(f.run(point("stale", ""))).rejects.toThrow("attachment identity"); + await f.run(point("u-1", "")); + expect(f.thread.turns).toEqual([]); + }); + it("uses rollback only for explicit legacy metadata", async () => { + const f = fixture([turn("1", "one"), turn("2", "two"), turn("3", "three")], "legacy"); + await f.run(point("u-2", "two")); + expect(f.client.threadRollback).toHaveBeenCalledWith({threadId: "session", numTurns: 2}); + expect(f.client.threadRevert).not.toHaveBeenCalled(); + }); + it.each([{}, {code:-32601}, new Error("legacy rollback suggested"), new Error("reload failed")])("never falls back based on a revert error shape %j", async error => { + const f = fixture([turn("1", "one")]); + f.client.threadRevert.mockRejectedValue(error); + await expect(f.run(point("u-1", "one"))).rejects.toEqual(error); + expect(f.client.threadRollback).not.toHaveBeenCalled(); + }); + it("rejects unknown native history mode before dispatch", async () => { + const f = fixture([turn("1", "one")]); + (f.thread as {historyMode: unknown}).historyMode = undefined; + await expect(f.run(point("u-1", "one"))).rejects.toThrow("Unknown history mode"); + expect(f.client.threadRevert).not.toHaveBeenCalled(); + }); + it("validates before cancelling and rejects a changed prefix after settlement", async () => { + const f = fixture([turn("1", "one"), turn("2", "two")]); + const stop = vi.fn(async () => {f.thread.turns[0] = turn("replaced", "other");}); + await expect(f.run(point("u-2", "two"), undefined, {beforeMutation:stop})).rejects.toThrow("history changed"); + expect(stop).toHaveBeenCalledOnce(); + expect(f.client.threadRevert).not.toHaveBeenCalled(); + await expect(f.run(point("missing", "absent"), undefined, {beforeMutation:stop})).rejects.toThrow("not found"); + expect(stop).toHaveBeenCalledOnce(); + }); + it("checks the actual retained prefix instead of trusting empty response turns", async () => { + const f = fixture([turn("1", "one"), turn("2", "two")]); + f.client.threadRevert.mockResolvedValue({thread:{...f.thread, turns:[]}}); + await expect(f.run(point("u-2", "two"))).rejects.toThrow("expected prefix"); + }); + it("drains native notifications after persisted-prefix verification", async () => { + const f = fixture([turn("1", "one")]); + const client = new CodexAcpClient(f.client as unknown as CodexAppServerClient); + const drain = vi.spyOn(client, "waitForSessionNotifications").mockResolvedValue(); + await client.rewindSession({sessionId:"session",beforeMessage:point("u-1","one")}); + expect(f.client.threadRevert).toHaveBeenCalledBefore(drain); + expect(drain).toHaveBeenCalledWith("session"); + }); +}); diff --git a/src/__tests__/SessionRuntime.test.ts b/src/__tests__/SessionRuntime.test.ts new file mode 100644 index 000000000..ddb78c88e --- /dev/null +++ b/src/__tests__/SessionRuntime.test.ts @@ -0,0 +1,51 @@ +import {describe, expect, it, vi} from "vitest"; +import {controlRuntime, readRuntime, runtimeControlParser, runtimeReadParser, type RuntimeSession} from "../SessionRuntime"; + +const session: RuntimeSession = { + sessionId: "s", cwd: "/workspace", additionalDirectories: ["/extra"], currentModelId: "m", + lastTokenUsage: null, totalTokenUsage: null, modelContextWindow: null, rateLimits: null, +}; +const client = () => ({ + listSkills: vi.fn(async () => ({data: []})), + listMcpServers: vi.fn(async () => ({data: [], nextCursor: null})), + installedPlugins: vi.fn(async () => ({marketplaces: [], marketplaceLoadErrors: []})), + reconcilePlugins: vi.fn(async () => ({changedPlugins: [], failedRemotePluginIds: [], failedMaterializationRemotePluginIds: []})), + reloadMcpServers: vi.fn(async () => {}), +}); +describe("runtime extension", () => { + it("rejects arbitrary methods, extra native parameters and missing identity", () => { + for (const input of [{sessionId: "s", resource: "config"}, {resource: "mcp"}, {sessionId: "s", resource: "mcp", threadId: "other"}]) { + expect(runtimeReadParser.safeParse(input).success).toBe(false); + } + expect(runtimeControlParser.safeParse({sessionId: "s", action: "delete"}).success).toBe(false); + }); + it("preserves unavailable context measurements as null", async () => { + const result = await readRuntime(client(), session, {sessionId: "s", resource: "context"}, new AbortController().signal); + expect(result).toMatchObject({data: {totalTokens: null, maxTokens: null, categories: null}}); + }); + it("refreshes skills for the actual session roots", async () => { + const native = client(); + await controlRuntime(native, session, {sessionId: "s", action: "reloadSkills"}); + expect(native.listSkills).toHaveBeenCalledWith({cwds: ["/workspace", "/extra"], forceReload: true}); + }); + it("does not claim MCP readiness from a reload acknowledgement", async () => { + const native = client(); + expect(await controlRuntime(native, session, {sessionId: "s", action: "reconnectMcp"})).toMatchObject({data: {scope: "provider", connectionsReady: false}}); + expect(native.reloadMcpServers).toHaveBeenCalledOnce(); + }); + it("rejects mismatched sessions before the native call", async () => { + const native = client(); + await expect(controlRuntime(native, session, {sessionId: "other", action: "reloadSkills"})).rejects.toThrow("Session mismatch"); + expect(native.listSkills).not.toHaveBeenCalled(); + }); + it("does not start a cancelled status read", async () => { + const native = client(); const abort = new AbortController(); abort.abort(); + expect(await readRuntime(native, session, {sessionId: "s", resource: "commands"}, abort.signal)).toMatchObject({reason: "cancelled"}); + expect(native.listSkills).not.toHaveBeenCalled(); + }); + it("does not publish the old provider's answer after replacement", async () => { + const native = client(); let current = true; + native.listSkills.mockImplementation(async () => {current = false; return {data: []};}); + expect(await readRuntime(native, session, {sessionId: "s", resource: "commands"}, new AbortController().signal, () => current)).toMatchObject({reason: "stale"}); + }); +}); diff --git a/src/__tests__/TitleGenerator.rewind.test.ts b/src/__tests__/TitleGenerator.rewind.test.ts new file mode 100644 index 000000000..5be7cad31 --- /dev/null +++ b/src/__tests__/TitleGenerator.rewind.test.ts @@ -0,0 +1,295 @@ +import {createHash} from "node:crypto"; +import {afterEach, beforeEach, describe, expect, it, vi} from "vitest"; +import type {CodexAppServerClient} from "../CodexAppServerClient"; +import type {SessionState} from "../CodexAcpServer"; +import type {SessionIndexHost} from "../SessionIndexService"; +import {SessionIndexTitles} from "../SessionIndexTitles"; +import {rewindSession} from "../SessionRewind"; +import {SessionWriteQueue} from "../SessionWriteQueue"; +import {TitleGenerator, type SerializeTitleWrite} from "../TitleGenerator"; +import type {Thread} from "../app-server/v2"; +import {deferred} from "./acp-test-utils"; + +const titleResult = (title: string) => ({ + turn: {items: [{type: "agentMessage", text: JSON.stringify({title})}]}, +}); +const flush = () => vi.advanceTimersByTimeAsync(0); + +function fixture(queued = false, historyMode: "legacy" | "paginated" = "legacy") { + const state = {sessionTitleSource: "unset"} as SessionState; + const thread = { + id: "session", historyMode, + turns: [{id: "turn", items: [{type: "userMessage", id: "user", content: [{type: "text", text: "discard this"}]}]}], + } as Thread; + const events: string[] = []; + const client = { + threadStart: vi.fn().mockResolvedValue({thread: {id: "ephemeral"}}), + runTurn: vi.fn().mockResolvedValue(titleResult("Discarded title")), + threadSetName: vi.fn().mockResolvedValue({}), + threadReadWithHistory: vi.fn(async () => ({thread: structuredClone(thread)})), + threadRollback: vi.fn(async () => { + events.push("rollback"); + thread.turns = []; + return {thread}; + }), + threadRevert: vi.fn(async () => { + events.push("revert"); + thread.turns = []; + return {thread}; + }), + }; + const writes = new SessionWriteQueue(); + const titles = new SessionIndexTitles({session: () => state} as unknown as SessionIndexHost, writes); + const serialize: SerializeTitleWrite = queued + ? (title, write) => titles.writeAutomaticTitle("session", title, write) + : (_title, write) => write(); + const writer = vi.fn(serialize); + const native = client as unknown as CodexAppServerClient; + const generator = new TitleGenerator(native, "session", "/test/cwd", () => state.sessionTitleSource, writer); + const beforeMutation = vi.fn(() => generator.invalidateForRewind()); + const rewind = () => rewindSession({ + sessionId: "session", + beforeMessage: { + messageId: "user", + messageFingerprint: `sha256:${createHash("sha256").update("discard this").digest("hex")}`, + messageOccurrence: 1, + }, + }, native, {beforeMutation}); + return {client, generator, writer, state, writes, events, thread, rewind, beforeMutation}; +} + +describe("TitleGenerator rewind invalidation", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it("does not wait for a pending model result and never writes it after rewind", async () => { + const f = fixture(); + const model = deferred>(); + f.client.runTurn.mockReturnValueOnce(model.promise); + f.generator.onTurnCompleted("discard this"); + await flush(); + expect(f.client.runTurn).toHaveBeenCalledOnce(); + + await expect(f.rewind()).resolves.toEqual({rewound: true}); + // Even waitForIdle no longer waits for the invalidated model request. + await f.generator.waitForIdle(5_000); + model.resolve(titleResult("Discarded title")); + await flush(); + + expect(f.writer).not.toHaveBeenCalled(); + expect(f.client.threadSetName).not.toHaveBeenCalled(); + }); + + it("skips the model if the ephemeral thread starts after invalidation", async () => { + const f = fixture(); + const start = deferred<{thread: {id: string}}>(); + f.client.threadStart.mockReturnValueOnce(start.promise); + f.generator.onTurnCompleted("discard this"); + await f.generator.invalidateForRewind(); + start.resolve({thread: {id: "old-ephemeral"}}); + await flush(); + + expect(f.client.runTurn).not.toHaveBeenCalled(); + f.generator.onTurnCompleted("replacement message"); + await flush(); + expect(f.client.runTurn).toHaveBeenCalledOnce(); + expect(f.client.runTurn.mock.calls[0]![0].input[0].text).toContain("replacement message"); + f.generator.observeRename(); + await f.generator.waitForIdle(5_000); + }); + + it("invalidates a queued write without waiting for the session queue", async () => { + const f = fixture(true); + const blocker = deferred(); + const blockingWrite = f.writes.run("session", () => blocker.promise); + f.generator.onTurnCompleted("discard this"); + await flush(); + expect(f.writer).toHaveBeenCalledOnce(); + expect(f.client.threadSetName).not.toHaveBeenCalled(); + + await expect(f.rewind()).resolves.toEqual({rewound: true}); + blocker.resolve(); + await blockingWrite; + await flush(); + expect(f.client.threadSetName).not.toHaveBeenCalled(); + expect(f.state.automaticTitleEcho).toBeUndefined(); + + f.client.runTurn.mockResolvedValueOnce(titleResult("Replacement title")); + f.generator.onTurnCompleted("replacement message"); + await flush(); + expect(f.client.threadSetName).toHaveBeenCalledExactlyOnceWith({threadId: "session", name: "Replacement title"}); + f.generator.observeRename(); + await f.generator.waitForIdle(5_000); + }); + + it("does not let an obsolete queued callback rearm a newer generation", async () => { + const f = fixture(true); + const blocker = deferred(); + const blockingWrite = f.writes.run("session", () => blocker.promise); + f.generator.onTurnCompleted("discard this"); + await flush(); + expect(f.writer).toHaveBeenCalledOnce(); + await f.generator.invalidateForRewind(); + + const current = deferred>(); + f.client.runTurn.mockReturnValueOnce(current.promise); + f.generator.onTurnCompleted("replacement"); + await flush(); + blocker.resolve(); + await blockingWrite; + await flush(); + f.generator.retryAfterFailedRename(); + f.generator.onTurnCompleted("duplicate"); + expect(f.client.threadStart).toHaveBeenCalledTimes(2); + expect(f.client.threadSetName).not.toHaveBeenCalled(); + + current.resolve(titleResult("Replacement title")); + await flush(); + expect(f.client.threadSetName).toHaveBeenCalledExactlyOnceWith({threadId: "session", name: "Replacement title"}); + f.generator.observeRename(); + await f.generator.waitForIdle(5_000); + }); + + it.each(["legacy", "paginated"] as const)("awaits a dispatched write acknowledgement before %s mutation, without waiting for its echo", async mode => { + const f = fixture(true, mode); + const ack = deferred(); + f.client.threadSetName.mockImplementationOnce(async () => { + await ack.promise; + f.events.push("title accepted"); + return {}; + }); + f.generator.onTurnCompleted("discard this"); + await flush(); + expect(f.client.threadSetName).toHaveBeenCalledOnce(); + + let settled = false; + const rewind = f.rewind().then(result => { settled = true; return result; }); + await vi.advanceTimersByTimeAsync(30_000); + expect(f.beforeMutation).toHaveBeenCalledOnce(); + expect(settled).toBe(false); + expect(f.events).toEqual([]); + expect(f.thread.turns).toHaveLength(1); + + ack.resolve(); + await expect(rewind).resolves.toEqual({rewound: true}); + expect(f.events).toEqual(["title accepted", mode === "legacy" ? "rollback" : "revert"]); + await f.generator.waitForIdle(5_000); + }); + + it.each([false, true])("fails closed on an unacknowledged dispatched write (already rejected: %s)", async alreadyRejected => { + const f = fixture(); + const ack = deferred>(); + f.client.threadSetName.mockReturnValueOnce(ack.promise); + f.generator.onTurnCompleted("discard this"); + await flush(); + expect(f.client.threadSetName).toHaveBeenCalledOnce(); + if (alreadyRejected) { + ack.reject(new Error("connection lost")); + await flush(); + } + + const failed = expect(f.rewind()).rejects.toThrow("title write"); + await flush(); + if (!alreadyRejected) ack.reject(new Error("connection lost")); + await failed; + // Best-effort generation swallowing the error must not turn a retry into success. + await expect(f.rewind()).rejects.toThrow("title write"); + expect(f.events).toEqual([]); + expect(f.thread.turns).toHaveLength(1); + }); + + it("lets a later generation finish while ignoring multiple obsolete model results", async () => { + const f = fixture(); + const first = deferred>(); + const second = deferred>(); + const current = deferred>(); + f.client.runTurn.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise).mockReturnValueOnce(current.promise); + f.generator.onTurnCompleted("first"); + await flush(); + await f.generator.invalidateForRewind(); + f.generator.onTurnCompleted("second"); + await flush(); + await f.generator.invalidateForRewind(); + f.generator.onTurnCompleted("current"); + await flush(); + + // A stale result must not set droppedForExplicitTitle for the current generation. + f.state.sessionTitleSource = "explicit"; + second.resolve(titleResult("Second discarded title")); + first.resolve(titleResult("First discarded title")); + await flush(); + f.state.sessionTitleSource = "unset"; + f.generator.retryAfterFailedRename(); + f.generator.onTurnCompleted("duplicate"); + expect(f.client.threadStart).toHaveBeenCalledTimes(3); + + let idle = false; + const wait = f.generator.waitForIdle(5_000).then(() => { idle = true; }); + await flush(); + expect(idle).toBe(false); + current.resolve(titleResult("Current title")); + await flush(); + expect(f.client.threadSetName).toHaveBeenCalledExactlyOnceWith({threadId: "session", name: "Current title"}); + f.generator.observeRename(); + await wait; + }); + + it.each(["explicit", "unknown", "existing"])("preserves %s title protection across rewind", async protection => { + const f = fixture(); + if (protection === "existing") f.generator.markExistingTitle(); + else f.state.sessionTitleSource = protection as "explicit" | "unknown"; + + await f.generator.invalidateForRewind(); + f.generator.onTurnCompleted("replacement"); + expect(f.client.threadStart).not.toHaveBeenCalled(); + }); + + it("preserves an explicit name set while invalidated generation is pending", async () => { + const f = fixture(); + const model = deferred>(); + f.client.runTurn.mockReturnValueOnce(model.promise); + f.generator.onTurnCompleted("discard this"); + await flush(); + f.state.sessionTitleSource = "explicit"; + f.generator.markExistingTitle(); + await f.generator.invalidateForRewind(); + model.resolve(titleResult("Discarded title")); + await flush(); + f.generator.retryAfterFailedRename(); + f.generator.onTurnCompleted("replacement"); + + expect(f.client.threadStart).toHaveBeenCalledOnce(); + expect(f.client.threadSetName).not.toHaveBeenCalled(); + }); + + it("allows a later title when an explicit rename attempted during invalidation fails", async () => { + const f = fixture(); + const model = deferred>(); + f.client.runTurn.mockReturnValueOnce(model.promise); + f.generator.onTurnCompleted("discard this"); + await flush(); + f.state.sessionTitleSource = "explicit"; + await f.generator.invalidateForRewind(); + f.state.sessionTitleSource = "unset"; + f.generator.retryAfterFailedRename(); + f.client.runTurn.mockResolvedValueOnce(titleResult("Replacement title")); + f.generator.onTurnCompleted("replacement"); + model.resolve(titleResult("Discarded title")); + await flush(); + + expect(f.client.threadSetName).toHaveBeenCalledExactlyOnceWith({threadId: "session", name: "Replacement title"}); + f.generator.observeRename(); + await f.generator.waitForIdle(5_000); + }); + + it("keeps an acknowledged title when rewind happens while its echo is pending", async () => { + const f = fixture(); + f.generator.onTurnCompleted("discard this"); + await flush(); + expect(f.client.threadSetName).toHaveBeenCalledOnce(); + await f.generator.invalidateForRewind(); + await f.generator.waitForIdle(5_000); + f.generator.onTurnCompleted("replacement"); + expect(f.client.threadStart).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/__tests__/app-server-recovery/extensions.test.ts b/src/__tests__/app-server-recovery/extensions.test.ts new file mode 100644 index 000000000..bff71ecef --- /dev/null +++ b/src/__tests__/app-server-recovery/extensions.test.ts @@ -0,0 +1,51 @@ +import {describe, expect, it} from "vitest"; +import {ResponseError} from "vscode-jsonrpc/node"; +import {createRecoveryFixture, initialize, requestsOf} from "./recovery-fixture"; + +async function setup() { + const fixture = createRecoveryFixture(); + await initialize(fixture); + const {sessionId} = await fixture.agent.newSession({cwd: "/work", mcpServers: []}); + return {...fixture, sessionId}; +} + +describe("session extensions after an app-server crash", () => { + it("restores the session before runtime reads and refreshes", async () => { + const fixture = await setup(); + const old = fixture.current(); + await fixture.kill(); + await expect(fixture.agent.readSessionRuntime({sessionId: fixture.sessionId, resource: "commands"})) + .resolves.toMatchObject({status: "ok", data: {data: []}}); + await expect(fixture.agent.controlSessionRuntime({sessionId: fixture.sessionId, action: "reloadSkills"})) + .resolves.toMatchObject({status: "ok"}); + expect(fixture.servers).toHaveLength(2); + expect(requestsOf(fixture.current(), "thread/resume")).toHaveLength(1); + expect(requestsOf(fixture.current(), "skills/list")).toContainEqual({cwds: ["/work"], forceReload: true}); + expect(requestsOf(old, "skills/list")).not.toContainEqual({cwds: ["/work"], forceReload: true}); + }); + + it("refuses lazy native resume when a persisted queue would auto-dispatch", async () => { + const fixture = await setup(); + await fixture.kill(); + fixture.answers.set("initialize", () => ({userAgent: "codex-test/0.160.1 (test)", codexHome: "/codex-home"})); + fixture.answers.set("thread/queue/list", params => (params as {threadId?: string}).threadId + ? {data: [{id: "persisted-message"}], nextCursor: null} + : new ResponseError(-32600, "Invalid request: missing field `threadId`")); + await expect(fixture.agent.readSessionRuntime({sessionId: fixture.sessionId, resource: "commands"})) + .rejects.toMatchObject({code: 1001, message: expect.stringContaining("Could not reopen session")}); + expect(requestsOf(fixture.current(), "thread/queue/list")).toContainEqual({threadId: fixture.sessionId, limit: 1}); + expect(requestsOf(fixture.current(), "thread/resume")).toEqual([]); + expect(requestsOf(fixture.current(), "turn/start")).toEqual([]); + // The integrated queue capability probes registration with invalid, target-free params only. + expect(requestsOf(fixture.current(), "thread/queue/start")).toEqual([{}]); + }); + + it("restarts before archiving a legacy client session without resuming it", async () => { + const fixture = await setup(); + await fixture.kill(); + await expect(fixture.agent.archiveSession(fixture.sessionId, true)) + .resolves.toEqual({sessionId: fixture.sessionId, archived: true}); + expect(requestsOf(fixture.current(), "thread/archive")).toEqual([{threadId: fixture.sessionId}]); + expect(requestsOf(fixture.current(), "thread/resume")).toEqual([]); + }); +}); diff --git a/src/__tests__/app-server-recovery/rewind-retired-titles.test.ts b/src/__tests__/app-server-recovery/rewind-retired-titles.test.ts new file mode 100644 index 000000000..7fba56193 --- /dev/null +++ b/src/__tests__/app-server-recovery/rewind-retired-titles.test.ts @@ -0,0 +1,95 @@ +import {createHash} from 'node:crypto'; +import {describe, it, expect, vi} from 'vitest'; +import {createRecoveryFixture, defaultAnswer, initialize, requestsOf} from './recovery-fixture'; +import {SESSION_REWIND_METHOD} from '../../SessionRewind'; +async function setup() { + const f=createRecoveryFixture(); await initialize(f,true,{_meta:{jetbrains:{air:{version:1,capabilities:['sessionIndex']}}}}); + const {sessionId}=await f.agent.newSession({cwd:'/work',mcpServers:[]}); + const metadata=defaultAnswer('thread/resume',{threadId:sessionId}) as any; + let turns:any[]=[{id:'old-turn',items:[{type:'userMessage',id:'old-user',content:[{type:'text',text:'discard this'}]}],status:'completed',error:null}]; + const read=()=>({thread:{...metadata.thread,historyMode:'legacy',turns}}); + f.answers.set('thread/read',read); + f.answers.set('thread/resume',()=>({...metadata,thread:read().thread,materialized:true})); + f.answers.set('thread/rollback',()=>{turns=[];return read();}); + const request={sessionId,beforeMessage:{messageId:'old-user',messageFingerprint:`sha256:${createHash('sha256').update('discard this').digest('hex')}`,messageOccurrence:1}}; + return {...f,sessionId,rewind:()=>f.agent.extMethod(SESSION_REWIND_METHOD,request)}; +} +const titleResult={turn:{items:[{type:'agentMessage',id:'title-item',text:'{"title":"Title from replaced state"}'}],status:'completed',error:null}}; +const flush=()=>new Promise(resolve=>setImmediate(resolve)); +describe('title work inherited from a replaced SessionState',()=>{ + for(const method of ['loadSession','resumeSession'] as const) it(`rejects obsolete title output after ${method} replaces its generator and rewind succeeds`,async()=>{ + const f=await setup(); const old=f.agent.getSessionState(f.sessionId).titleGen!; + const native=(f.agent as any).codexAcpClient.appServerClient; + let release!:(v:any)=>void; + const model=vi.spyOn(native,'runTurn').mockImplementation(()=>new Promise(r=>{release=r;})); + old.onTurnCompleted('discard this'); + await vi.waitFor(()=>expect(model).toHaveBeenCalledOnce()); + const loadWait=vi.spyOn(old,'waitForIdle'); + if(method==='loadSession') { + vi.useFakeTimers({toFake:['setTimeout','clearTimeout']}); + try { + const opened=f.agent.loadSession({sessionId:f.sessionId,cwd:'/work',mcpServers:[]}); + await vi.advanceTimersByTimeAsync(0); + expect(loadWait).toHaveBeenCalledWith(10_000); + await vi.advanceTimersByTimeAsync(10_000); + await opened; + } finally { vi.useRealTimers(); } + } else { + await f.agent.resumeSession({sessionId:f.sessionId,cwd:'/work',mcpServers:[]}); + expect(loadWait).not.toHaveBeenCalled(); + } + expect(f.agent.getSessionState(f.sessionId).titleGen).not.toBe(old); + await expect(f.rewind()).resolves.toEqual({rewound:true}); + release(titleResult); await flush(); + const writes=requestsOf(f.current(),'thread/name/set'); + old.observeRename(); await old.waitForIdle(50); + expect(writes).toEqual([]); + }); + it('retains the drain fence for a title write dispatched by a generator replaced by resume',async()=>{ + const f=await setup(); const old=f.agent.getSessionState(f.sessionId).titleGen!; + const native=(f.agent as any).codexAcpClient.appServerClient; + vi.spyOn(native,'runTurn').mockResolvedValue(titleResult); + f.answers.set('thread/name/set',()=>undefined); + old.onTurnCompleted('discard this'); + await vi.waitFor(()=>expect(f.current().rpc.hasPending('thread/name/set')).toBe(true)); + await f.agent.resumeSession({sessionId:f.sessionId,cwd:'/work',mcpServers:[]}); + const rewind=f.rewind(); rewind.catch(()=>{}); + await flush(); + const mutationsBeforeAck=requestsOf(f.current(),'thread/rollback'); + f.current().rpc.resolve('thread/name/set',{}); + await rewind; await flush(); old.observeRename(); await old.waitForIdle(50); + expect(mutationsBeforeAck).toEqual([]); + }); + + it('retains unacknowledged title work across close and reopen',async()=>{ + const f=await setup(); const old=f.agent.getSessionState(f.sessionId).titleGen!; + const native=(f.agent as any).codexAcpClient.appServerClient; + const model=vi.spyOn(native,'runTurn').mockResolvedValue(titleResult); + f.answers.set('thread/name/set',()=>undefined); + old.onTurnCompleted('discard this'); + await vi.waitFor(()=>expect(f.current().rpc.hasPending('thread/name/set')).toBe(true)); + await f.agent.closeSession({sessionId:f.sessionId}); + await f.agent.resumeSession({sessionId:f.sessionId,cwd:'/work',mcpServers:[]}); + const rewind=f.rewind(); rewind.catch(()=>{}); await flush(); + expect(requestsOf(f.current(),'thread/rollback')).toEqual([]); + f.current().rpc.resolve('thread/name/set',{}); + await expect(rewind).resolves.toEqual({rewound:true}); + old.onTurnCompleted('must not restart a retired generator'); + expect(model).toHaveBeenCalledOnce(); + }); + + it('keeps rejected writes from retired generators fenced on later attempts',async()=>{ + const f=await setup(); const old=f.agent.getSessionState(f.sessionId).titleGen!; + const native=(f.agent as any).codexAcpClient.appServerClient; + vi.spyOn(native,'runTurn').mockResolvedValue(titleResult); + f.answers.set('thread/name/set',()=>new Error('lost title acknowledgement')); + old.onTurnCompleted('discard this'); + await vi.waitFor(()=>expect(requestsOf(f.current(),'thread/name/set')).toHaveLength(1)); + await old.waitForIdle(50); + await f.agent.resumeSession({sessionId:f.sessionId,cwd:'/work',mcpServers:[]}); + for(let attempt=0;attempt<2;attempt++) { + await expect(f.rewind()).rejects.toThrow('Automatic title write was not acknowledged'); + } + expect(requestsOf(f.current(),'thread/rollback')).toEqual([]); + }); +}); diff --git a/src/__tests__/app-server-recovery/rewind.test.ts b/src/__tests__/app-server-recovery/rewind.test.ts new file mode 100644 index 000000000..ea8688d16 --- /dev/null +++ b/src/__tests__/app-server-recovery/rewind.test.ts @@ -0,0 +1,154 @@ +import {createHash} from "node:crypto"; +import {describe, expect, it, vi} from "vitest"; +import {SESSION_REWIND_METHOD} from "../../SessionRewind"; +import type {CodexAcpClient} from "../../CodexAcpClient"; +import {createRecoveryFixture, defaultAnswer, initialize, requestsOf} from "./recovery-fixture"; + +async function setup() { + const fixture = createRecoveryFixture(); + await initialize(fixture, true, {_meta: {jetbrains: {air: {version: 1, capabilities: ["sessionIndex"]}}}}); + const {sessionId} = await fixture.agent.newSession({cwd: "/work", mcpServers: []}); + const metadata = defaultAnswer("thread/resume", {threadId: sessionId}) as {thread: Record}; + let turns: unknown[] = [{ + id: "turn-original", items: [{type: "userMessage", id: "user-original", content: [{type: "text", text: "original"}]}], + status: "completed", error: null, + }]; + fixture.answers.set("thread/read", () => ({thread: {...metadata.thread, historyMode: "legacy", turns}})); + fixture.answers.set("thread/rollback", () => {turns = []; return {thread: {...metadata.thread, turns}};}); + const request = { + sessionId, + beforeMessage: {messageId: "user-original", messageFingerprint: `sha256:${createHash("sha256").update("original").digest("hex")}`, messageOccurrence: 1}, + }; + return {...fixture, sessionId, rewind: () => fixture.agent.extMethod(SESSION_REWIND_METHOD, request)}; +} + +describe("rewind with current app-server recovery and session index", () => { + it("discards a pending automatic title result when rewinding an idle session", async () => { + const fixture = await setup(); + const title = fixture.agent.getSessionState(fixture.sessionId).titleGen!; + const native = (fixture.agent as unknown as {codexAcpClient: CodexAcpClient}).codexAcpClient.appServerClient; + let release!: (value: unknown) => void; + const model = vi.spyOn(native, "runTurn").mockImplementation(() => new Promise(resolve => { + release = value => resolve(value as never); + })); + title.onTurnCompleted("original"); + await vi.waitFor(() => expect(model).toHaveBeenCalledOnce()); + await expect(fixture.rewind()).resolves.toEqual({rewound: true}); + release({turn: {items: [{type: "agentMessage", text: '{"title":"Discarded title"}'}]}}); + await new Promise(resolve => setImmediate(resolve)); + expect(requestsOf(fixture.current(), "thread/name/set")).toEqual([]); + }); + + it("refuses history mutation if an earlier title write does not settle before the deadline", async () => { + const fixture = await setup(); + const title = fixture.agent.getSessionState(fixture.sessionId).titleGen!; + const native = (fixture.agent as unknown as {codexAcpClient: CodexAcpClient}).codexAcpClient.appServerClient; + vi.spyOn(native, "runTurn").mockResolvedValue({turn: {items: [{type: "agentMessage", text: '{"title":"Pending title"}'}]}} as never); + fixture.answers.set("thread/name/set", () => undefined); + title.onTurnCompleted("original"); + await vi.waitFor(() => expect(requestsOf(fixture.current(), "thread/name/set")).toHaveLength(1)); + vi.useFakeTimers(); + try { + const pending = fixture.rewind(); + const rejected = expect(pending).rejects.toMatchObject({data: expect.stringContaining("title write is still pending")}); + await vi.advanceTimersByTimeAsync(10_000); + await rejected; + expect(requestsOf(fixture.current(), "thread/rollback")).toEqual([]); + fixture.current().rpc.resolve("thread/name/set", {}); + await expect(fixture.rewind()).resolves.toEqual({rewound: true}); + expect(requestsOf(fixture.current(), "thread/rollback")).toHaveLength(1); + } finally { + vi.useRealTimers(); + } + }); + + it("lets close interrupt the prompt that an earlier provider update is waiting for", async () => { + const fixture = await setup(); + fixture.agent.getSessionState(fixture.sessionId).titleGen?.markExistingTitle(); + const native = fixture.current(); + const finish = () => native.rpc.notify({method: "turn/completed", params: { + threadId: fixture.sessionId, + turn: {id: "active-turn", items: [], status: "interrupted", error: null}, + }}); + fixture.answers.set("turn/start", () => ({turn: {id: "active-turn", items: [], status: "inProgress", error: null}})); + fixture.answers.set("turn/interrupt", () => {setImmediate(finish); return {};}); + const prompt = fixture.agent.prompt({sessionId: fixture.sessionId, prompt: [{type: "text", text: "running"}]}); + await vi.waitFor(() => expect(fixture.agent.getSessionState(fixture.sessionId).currentTurnId).toBe("active-turn")); + const provider = fixture.agent.setProvider({providerId: "openai", apiType: "openai", baseUrl: "https://example.test/v1"}); + const close = fixture.agent.closeSession({sessionId: fixture.sessionId}); + try { + await vi.waitFor(() => expect(requestsOf(native, "turn/interrupt")).toHaveLength(1)); + } finally { + finish(); + await Promise.all([prompt, provider, close]); + } + }); + + it("resumes a crashed session before reading and mutating its history on the replacement process", async () => { + const fixture = await setup(); + const old = fixture.current(); + await fixture.kill(); + await expect(fixture.rewind()).resolves.toEqual({rewound: true}); + expect(fixture.servers).toHaveLength(2); + expect(requestsOf(old, "thread/rollback")).toEqual([]); + expect(requestsOf(fixture.current(), "thread/resume")).toHaveLength(1); + expect(requestsOf(fixture.current(), "thread/rollback")).toEqual([{threadId: fixture.sessionId, numTurns: 1}]); + }); + + it("blocks index writes and a second rewind while the replacement session is still opening", async () => { + const fixture = await setup(); + await fixture.kill(); + fixture.answers.set("thread/resume", () => undefined); + const pending = fixture.rewind(); + await vi.waitFor(() => expect(requestsOf(fixture.current(), "thread/resume")).toHaveLength(1)); + for (const attempt of [ + () => fixture.rewind(), + () => fixture.agent.renameSession({sessionId: fixture.sessionId, title: "new title"}), + () => fixture.agent.setSessionArchived({sessionId: fixture.sessionId}, true), + () => fixture.agent.setSessionArchived({sessionId: fixture.sessionId}, false), + ]) await expect(attempt()).rejects.toMatchObject({data: expect.stringContaining("rewinding")}); + expect(requestsOf(fixture.current(), "thread/rollback")).toEqual([]); + fixture.current().rpc.resolve("thread/resume", defaultAnswer("thread/resume", {threadId: fixture.sessionId})); + await expect(pending).resolves.toEqual({rewound: true}); + }); + + it("leaves history untouched when reopen fails and permits a fresh attempt before mutation", async () => { + const fixture = await setup(); + await fixture.kill(); + fixture.answers.set("thread/resume", () => new Error("cannot reopen")); + await expect(fixture.rewind()).rejects.toThrow("cannot reopen"); + expect(requestsOf(fixture.current(), "thread/rollback")).toEqual([]); + fixture.answers.delete("thread/resume"); + await expect(fixture.rewind()).resolves.toEqual({rewound: true}); + expect(requestsOf(fixture.current(), "thread/rollback")).toHaveLength(1); + }); + + it("does not replay a dispatched rollback when its process dies before acknowledging", async () => { + const fixture = await setup(); + fixture.answers.set("thread/rollback", () => undefined); + const pending = fixture.rewind(); + const failure = expect(pending).rejects.toMatchObject({code: 1001}); + await vi.waitFor(() => expect(requestsOf(fixture.current(), "thread/rollback")).toHaveLength(1)); + await fixture.kill(); + await failure; + await fixture.agent.listSessions({}); + await expect(fixture.rewind()).rejects.toMatchObject({data: expect.stringContaining("outcome is uncertain")}); + expect(requestsOf(fixture.current(), "thread/rollback")).toEqual([]); + expect(fixture.servers.flatMap(server => requestsOf(server, "thread/rollback"))).toHaveLength(1); + }); + + it("does not overtake an earlier session index rename", async () => { + const fixture = await setup(); + let release!: () => void; + const rename = vi.spyOn(fixture.agent.sessionIndex, "rename").mockImplementation(() => new Promise(resolve => { + release = () => resolve({}); + })); + const pending = fixture.agent.renameSession({sessionId: fixture.sessionId, title: "new title"}); + await vi.waitFor(() => expect(rename).toHaveBeenCalledOnce()); + await expect(fixture.rewind()).rejects.toMatchObject({data: expect.stringContaining("update is in progress")}); + expect(requestsOf(fixture.current(), "thread/rollback")).toEqual([]); + release(); + await pending; + await expect(fixture.rewind()).resolves.toEqual({rewound: true}); + }); +}); diff --git a/src/__tests__/fixtures/CodexProcessTermination.cjs b/src/__tests__/fixtures/CodexProcessTermination.cjs new file mode 100644 index 000000000..15d619082 --- /dev/null +++ b/src/__tests__/fixtures/CodexProcessTermination.cjs @@ -0,0 +1,48 @@ +// Only synthetic processes: no Codex configuration, credentials, or model traffic. +const {spawn} = require("node:child_process"); +const {writeFileSync, existsSync} = require("node:fs"); + +let [role, directory, mode] = process.argv.slice(2); +if (role === "app-server") { + role = "stdio"; + directory = process.env.CODEX_PROCESS_TERMINATION_FIXTURE_DIR; +} +const write = (name, value) => writeFileSync(`${directory}/${name}`, String(value)); +if (role === "stdio") { + process.stdin.resume(); + process.stdin.on("data", data => process.stdout.write(data)); + process.stdin.on("end", () => { + process.stderr.write("fixture EOF\n", () => process.exit(17)); + }); + process.on("SIGTERM", () => { + process.stderr.write("fixture SIGTERM\n", () => process.exit(29)); + }); + write("ready", process.pid); + setTimeout(() => process.exit(99), 30_000).unref(); +} else if (role === "child") { + process.on("SIGTERM", () => {}); + write("child.pid", process.pid); + // A safety lease bounds the lifetime even if a test crashes before cleanup. + const lease = setTimeout(() => process.exit(0), 30_000); + const heartbeat = setInterval(() => { + if (existsSync(`${directory}/cleanup`)) { + clearInterval(heartbeat); + clearTimeout(lease); + process.exit(0); + } + write("heartbeat", Date.now()); + }, 25); +} else { + process.on("SIGTERM", () => {}); + const childArgs = typeof Bun !== "undefined" && Bun.main.startsWith("/$bunfs/") + ? ["child", directory, mode] : [__filename, "child", directory, mode]; + const child = spawn(process.execPath, childArgs, {stdio: "inherit"}); + child.on("error", () => process.exit(1)); + write("parent.pid", process.pid); + setTimeout(() => process.exit(0), 30_000); + setInterval(() => { + if (existsSync(`${directory}/exit-parent`)) process.exit(23); + if (existsSync(`${directory}/signal-parent`)) process.kill(process.pid, "SIGKILL"); + if (existsSync(`${directory}/cleanup`)) process.exit(0); + }, 25); +} diff --git a/src/__tests__/fixtures/CodexProcessTermination.compiled.ts b/src/__tests__/fixtures/CodexProcessTermination.compiled.ts new file mode 100644 index 000000000..ba63ede7e --- /dev/null +++ b/src/__tests__/fixtures/CodexProcessTermination.compiled.ts @@ -0,0 +1,90 @@ +// Compile this entrypoint with `bun build --compile` and run with: +// +// Keep build output outside dist. Linux tests need an init that reaps orphans. +import assert from "node:assert/strict"; +import {mkdtempSync, readFileSync, existsSync, writeFileSync, rmSync, mkdirSync} from "node:fs"; +import {tmpdir} from "node:os"; +import path from "node:path"; +import {once} from "node:events"; +import {runInternalProcessHelper} from "../../CodexProcessHelpers"; +await runInternalProcessHelper(); +const {startCodexConnection, terminateCodexConnection} = await import("../../CodexJsonRpcConnection"); + +const [node, fixture] = process.argv.slice(2); +assert(node && fixture, "Pass Node and fixture paths"); +const isBun = Boolean(process.versions["bun"]); +if (isBun) assert.notEqual(process.execPath, node); +const directory = mkdtempSync(path.join(tmpdir(), "CodexProcessTermination-compiled-")); +const env = {...process.env, PATH: directory, APP_SERVER_LOGS: ""}; +const providerArgs = (role: string, dir: string) => fixture === "native" ? [role, dir] : [fixture, role, dir]; +const until = async (check: () => boolean) => { + const deadline = Date.now() + 10_000; + while (!check()) { + assert(Date.now() < deadline, "Fixture wait timed out"); + await new Promise(resolve => setTimeout(resolve, 20)); + } +}; +try { + for (const mode of ["kill", "parent-first"]) { + const caseDir = path.join(directory, mode); + mkdirSync(caseDir); + const native = startCodexConnection(node, env, providerArgs("parent", caseDir)); + native.process.stderr.on("data", data => process.stderr.write(data)); + try { + await until(() => existsSync(path.join(caseDir, "heartbeat"))); + const pids = ["parent.pid", "child.pid"].map(name => Number(readFileSync(path.join(caseDir, name), "utf8"))); + if (mode === "parent-first") { + const exited = once(native.process, "exit"); + writeFileSync(path.join(caseDir, "exit-parent"), "exit"); + await exited; + assert.equal(native.process.exitCode, 23); + } + await terminateCodexConnection(native); + for (const pid of pids) assert.throws(() => process.kill(pid, 0), {code: "ESRCH"}); + await terminateCodexConnection(native); + console.log(`PASS compiled ${mode}: parent and child gone`); + } finally { + writeFileSync(path.join(caseDir, "cleanup"), "stop"); + await terminateCodexConnection(native).catch(() => {}); + } + } + const native = startCodexConnection(node, env, providerArgs("stdio", directory)); + let stdout = ""; + let stderr = ""; + native.process.stdout.on("data", data => {stdout += data;}); + native.process.stderr.on("data", data => {stderr += data;}); + await until(() => existsSync(path.join(directory, "ready"))); + const line = '{"method":"fixture/echo","params":{"text":"你好"}}\n'; + native.process.stdin.write(line); + await until(() => stdout === line); + const closed = once(native.process, "close"); + native.process.stdin.end(); + await closed; + assert.equal(native.process.exitCode, 17); + assert.equal(stderr, "fixture EOF\n"); + await terminateCodexConnection(native); + console.log("PASS compiled EOF: exact stdout/stderr and exit code 17"); + + const stalledDir = path.join(directory, "stalled"); + mkdirSync(stalledDir); + const stalled = startCodexConnection(node, env, providerArgs("parent", stalledDir)); + await until(() => existsSync(path.join(stalledDir, "heartbeat"))); + const group = stalled.process.pid!; + process.kill(-group, "SIGSTOP"); // Hold this live owned group so it cannot service the control pipe. + try { + await assert.rejects(terminateCodexConnection(stalled), /timed out; tree state is unknown/); + assert.equal(stalled.process.exitCode, null); + assert.equal(stalled.process.signalCode, null); + } finally { + process.kill(-group, "SIGCONT"); // The stopped group still pins its identity. + } + await until(() => { + try {process.kill(-group, 0); return false;} + catch (error) {if ((error as NodeJS.ErrnoException).code === "ESRCH") return true; throw error;} + }); + console.log("PASS compiled timeout: stopped group rejected after deadline, then reaped after resume"); + + +} finally { + rmSync(directory, {recursive: true, force: true}); +} diff --git a/src/__tests__/fixtures/CodexProcessTermination.entrypoint.cjs b/src/__tests__/fixtures/CodexProcessTermination.entrypoint.cjs new file mode 100644 index 000000000..a2904a998 --- /dev/null +++ b/src/__tests__/fixtures/CodexProcessTermination.entrypoint.cjs @@ -0,0 +1,43 @@ +// Run with node . Both children use +// a PATH without Node; this harness itself does not supply an interpreter to them. +const {spawn, spawnSync} = require("node:child_process"); +const {mkdtempSync, existsSync, rmSync} = require("node:fs"); +const {tmpdir} = require("node:os"); +const path = require("node:path"); +const assert = require("node:assert/strict"); +const {once} = require("node:events"); + +(async () => { + const [acp, fixture] = process.argv.slice(2); + const dir = mkdtempSync(path.join(tmpdir(), "CodexProcessTermination-entry-")); + try { + const env = {...process.env, PATH: dir, CODEX_PATH: fixture, + APP_SERVER_LOGS: "", CODEX_CONFIG: "{}", DEFAULT_AUTH_REQUEST: "", + CODEX_PROCESS_TERMINATION_FIXTURE_DIR: dir}; + const provider = spawn(acp, [], {env, stdio: "pipe"}); + let stderr = ""; + provider.stderr.on("data", data => {stderr += data;}); + const exited = once(provider, "exit"); + const deadline = Date.now() + 10_000; + while (!existsSync(path.join(dir, "ready"))) { + assert(Date.now() < deadline, `ACP did not launch native fixture: ${stderr}`); + await new Promise(resolve => setTimeout(resolve, 20)); + } + provider.stdin.end(); + const [code] = await exited; + assert.equal(code, 0, stderr); + console.log("PASS compiled ACP entrypoint: CODEX_PATH native launch and EOF, no Node on PATH"); + for (const role of ["supervisor", "guardian", "unknown"]) { + const result = spawnSync(acp, [`--internal-owned-process-${role}`], { + env: {...env, CODEX_CONFIG: "invalid JSON", APP_SERVER_LOGS: path.join(dir, "forbidden-logs")}, + encoding: "utf8", timeout: 5000, + }); + assert.equal(result.status, 1); + assert(!existsSync(path.join(dir, "forbidden-logs"))); + assert(!result.stderr.includes("JSON")); + } + console.log("PASS compiled ACP helper rejection: no private fd, no config/log initialization"); + } finally { + rmSync(dir, {recursive: true, force: true}); + } +})().catch(error => {console.error(error); process.exitCode = 1;}); diff --git a/src/__tests__/scenarios/baseline.ts b/src/__tests__/scenarios/baseline.ts index a7fe89b96..0a69b24db 100644 --- a/src/__tests__/scenarios/baseline.ts +++ b/src/__tests__/scenarios/baseline.ts @@ -1,3 +1,7 @@ +import {fileRevertCapability} from "../../SessionFileRevert"; +import {runtimeCapability} from "../../SessionRuntime"; +import {archiveCapability} from "../../SessionArchive"; +import {sessionDiscoveryCapability} from "../../SessionDiscovery"; import {canonical, type RecordedMessage} from "./scenario-harness"; /** @@ -141,6 +145,16 @@ const MCP_COMMAND = { export function withFeatureChanges(messages: RecordedMessage[]): RecordedMessage[] { const baselineMcp = JSON.stringify(canonical(BASELINE_MCP_COMMAND)); return messages.map(message => { + if (message.direction === "response" && message.method === "initialize") { + const params = structuredClone(message.params) as Json; + const meta = params["_meta"] as Json; + meta["runtime"] = runtimeCapability(); + meta["archive"] = archiveCapability(); + meta["fileRevert"] = fileRevertCapability(); + meta["discovery"] = sessionDiscoveryCapability(); + (meta["steering"] as Json)["idleBehavior"] = ["promptRequired"]; + return {...message, params}; + } const update = sessionUpdate(message); if (update === undefined || update["sessionUpdate"] !== "available_commands_update") return message; const commands = (update["availableCommands"] as unknown[]).map(command => diff --git a/src/__tests__/scenarios/data/air/agent-message-and-reasoning.jsonl b/src/__tests__/scenarios/data/air/agent-message-and-reasoning.jsonl index e145bc278..44395364d 100644 --- a/src/__tests__/scenarios/data/air/agent-message-and-reasoning.jsonl +++ b/src/__tests__/scenarios/data/air/agent-message-and-reasoning.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","customInstructions","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","customInstructions","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/background-terminal.jsonl b/src/__tests__/scenarios/data/air/background-terminal.jsonl index dfb2dd265..cd02e4a8a 100644 --- a/src/__tests__/scenarios/data/air/background-terminal.jsonl +++ b/src/__tests__/scenarios/data/air/background-terminal.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","customInstructions","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","customInstructions","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/collab-agent.jsonl b/src/__tests__/scenarios/data/air/collab-agent.jsonl index 5776128ea..90c6a5cff 100644 --- a/src/__tests__/scenarios/data/air/collab-agent.jsonl +++ b/src/__tests__/scenarios/data/air/collab-agent.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","customInstructions","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","customInstructions","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/collab-controls.jsonl b/src/__tests__/scenarios/data/air/collab-controls.jsonl index e231ae372..2ea75aa20 100644 --- a/src/__tests__/scenarios/data/air/collab-controls.jsonl +++ b/src/__tests__/scenarios/data/air/collab-controls.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","customInstructions","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","customInstructions","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/command-approval.jsonl b/src/__tests__/scenarios/data/air/command-approval.jsonl index 3a9a295eb..d932bf27a 100644 --- a/src/__tests__/scenarios/data/air/command-approval.jsonl +++ b/src/__tests__/scenarios/data/air/command-approval.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","customInstructions","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","customInstructions","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/command-failed.jsonl b/src/__tests__/scenarios/data/air/command-failed.jsonl index f362f218c..6575c6b65 100644 --- a/src/__tests__/scenarios/data/air/command-failed.jsonl +++ b/src/__tests__/scenarios/data/air/command-failed.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","customInstructions","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","customInstructions","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/command-output-stdin.jsonl b/src/__tests__/scenarios/data/air/command-output-stdin.jsonl index b799a78ad..f84b3d980 100644 --- a/src/__tests__/scenarios/data/air/command-output-stdin.jsonl +++ b/src/__tests__/scenarios/data/air/command-output-stdin.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","customInstructions","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","customInstructions","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/command-without-streamed-output.jsonl b/src/__tests__/scenarios/data/air/command-without-streamed-output.jsonl index 62ed2c03a..7451cae7b 100644 --- a/src/__tests__/scenarios/data/air/command-without-streamed-output.jsonl +++ b/src/__tests__/scenarios/data/air/command-without-streamed-output.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","customInstructions","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","customInstructions","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/context-compaction.jsonl b/src/__tests__/scenarios/data/air/context-compaction.jsonl index e7ce2cbbd..662869007 100644 --- a/src/__tests__/scenarios/data/air/context-compaction.jsonl +++ b/src/__tests__/scenarios/data/air/context-compaction.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","customInstructions","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","customInstructions","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/dynamic-tool.jsonl b/src/__tests__/scenarios/data/air/dynamic-tool.jsonl index 639264187..c8ec9d1b5 100644 --- a/src/__tests__/scenarios/data/air/dynamic-tool.jsonl +++ b/src/__tests__/scenarios/data/air/dynamic-tool.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","customInstructions","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","customInstructions","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/file-change-approval.jsonl b/src/__tests__/scenarios/data/air/file-change-approval.jsonl index 06fe6a97c..1ac379001 100644 --- a/src/__tests__/scenarios/data/air/file-change-approval.jsonl +++ b/src/__tests__/scenarios/data/air/file-change-approval.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","customInstructions","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","customInstructions","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/file-changes.jsonl b/src/__tests__/scenarios/data/air/file-changes.jsonl index 2eb2ee2af..fb5b891e9 100644 --- a/src/__tests__/scenarios/data/air/file-changes.jsonl +++ b/src/__tests__/scenarios/data/air/file-changes.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","customInstructions","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","customInstructions","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/fuzzy-file-search.jsonl b/src/__tests__/scenarios/data/air/fuzzy-file-search.jsonl index aba2ef5a9..5e32b87b8 100644 --- a/src/__tests__/scenarios/data/air/fuzzy-file-search.jsonl +++ b/src/__tests__/scenarios/data/air/fuzzy-file-search.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","customInstructions","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","customInstructions","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/goal-update.jsonl b/src/__tests__/scenarios/data/air/goal-update.jsonl index d9a271e9a..991f1b010 100644 --- a/src/__tests__/scenarios/data/air/goal-update.jsonl +++ b/src/__tests__/scenarios/data/air/goal-update.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","customInstructions","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","customInstructions","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/guardian-review.jsonl b/src/__tests__/scenarios/data/air/guardian-review.jsonl index 37e35e388..cbbe6beea 100644 --- a/src/__tests__/scenarios/data/air/guardian-review.jsonl +++ b/src/__tests__/scenarios/data/air/guardian-review.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","customInstructions","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","customInstructions","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/history-replay.jsonl b/src/__tests__/scenarios/data/air/history-replay.jsonl index 09f8186ca..ffaff2153 100644 --- a/src/__tests__/scenarios/data/air/history-replay.jsonl +++ b/src/__tests__/scenarios/data/air/history-replay.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","customInstructions","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","customInstructions","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/load","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/image-generation.jsonl b/src/__tests__/scenarios/data/air/image-generation.jsonl index 4197ce556..b529d6265 100644 --- a/src/__tests__/scenarios/data/air/image-generation.jsonl +++ b/src/__tests__/scenarios/data/air/image-generation.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","customInstructions","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","customInstructions","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/image-view.jsonl b/src/__tests__/scenarios/data/air/image-view.jsonl index 6a40d5c5e..291fd4946 100644 --- a/src/__tests__/scenarios/data/air/image-view.jsonl +++ b/src/__tests__/scenarios/data/air/image-view.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","customInstructions","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","customInstructions","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/late-subagent-update.jsonl b/src/__tests__/scenarios/data/air/late-subagent-update.jsonl index e79ae803c..7ea78a057 100644 --- a/src/__tests__/scenarios/data/air/late-subagent-update.jsonl +++ b/src/__tests__/scenarios/data/air/late-subagent-update.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","customInstructions","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","customInstructions","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/mcp-elicitation.jsonl b/src/__tests__/scenarios/data/air/mcp-elicitation.jsonl index 65ee9aeaa..d4a696a1d 100644 --- a/src/__tests__/scenarios/data/air/mcp-elicitation.jsonl +++ b/src/__tests__/scenarios/data/air/mcp-elicitation.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","customInstructions","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","customInstructions","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/mcp-startup-failure.jsonl b/src/__tests__/scenarios/data/air/mcp-startup-failure.jsonl index a95fab572..7f91717cf 100644 --- a/src/__tests__/scenarios/data/air/mcp-startup-failure.jsonl +++ b/src/__tests__/scenarios/data/air/mcp-startup-failure.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","customInstructions","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","customInstructions","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"content":[{"content":{"text":"[codex-acp forwarded startup error] MCP server `db` failed to start: boom","type":"text"},"type":"content"}],"kind":"other","sessionUpdate":"tool_call","status":"failed","title":"mcp__db__startup","toolCallId":"mcp_startup.db."}}} diff --git a/src/__tests__/scenarios/data/air/mcp-tool-approval.jsonl b/src/__tests__/scenarios/data/air/mcp-tool-approval.jsonl index 2651a559a..9f4d9a61c 100644 --- a/src/__tests__/scenarios/data/air/mcp-tool-approval.jsonl +++ b/src/__tests__/scenarios/data/air/mcp-tool-approval.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","customInstructions","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","customInstructions","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/mcp-tool.jsonl b/src/__tests__/scenarios/data/air/mcp-tool.jsonl index 40e8fa3bf..9f54ab789 100644 --- a/src/__tests__/scenarios/data/air/mcp-tool.jsonl +++ b/src/__tests__/scenarios/data/air/mcp-tool.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","customInstructions","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","customInstructions","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/mcp-url-elicitation.jsonl b/src/__tests__/scenarios/data/air/mcp-url-elicitation.jsonl index b13da4a5c..da372f3a6 100644 --- a/src/__tests__/scenarios/data/air/mcp-url-elicitation.jsonl +++ b/src/__tests__/scenarios/data/air/mcp-url-elicitation.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","customInstructions","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","customInstructions","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/native-subagent-session.jsonl b/src/__tests__/scenarios/data/air/native-subagent-session.jsonl index d615abd7a..24eba9211 100644 --- a/src/__tests__/scenarios/data/air/native-subagent-session.jsonl +++ b/src/__tests__/scenarios/data/air/native-subagent-session.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","customInstructions","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","customInstructions","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/nested-subagent-session.jsonl b/src/__tests__/scenarios/data/air/nested-subagent-session.jsonl index 68ed64971..4d211b334 100644 --- a/src/__tests__/scenarios/data/air/nested-subagent-session.jsonl +++ b/src/__tests__/scenarios/data/air/nested-subagent-session.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","customInstructions","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","customInstructions","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/network-and-sandbox-approval.jsonl b/src/__tests__/scenarios/data/air/network-and-sandbox-approval.jsonl index 56f5aa178..f7069976e 100644 --- a/src/__tests__/scenarios/data/air/network-and-sandbox-approval.jsonl +++ b/src/__tests__/scenarios/data/air/network-and-sandbox-approval.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","customInstructions","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","customInstructions","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/plan-deltas-and-turn-plan.jsonl b/src/__tests__/scenarios/data/air/plan-deltas-and-turn-plan.jsonl index 05e8acf72..a4117ec17 100644 --- a/src/__tests__/scenarios/data/air/plan-deltas-and-turn-plan.jsonl +++ b/src/__tests__/scenarios/data/air/plan-deltas-and-turn-plan.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","customInstructions","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","customInstructions","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/plan-review-permission.jsonl b/src/__tests__/scenarios/data/air/plan-review-permission.jsonl index 60b368ecc..75d6cf7c1 100644 --- a/src/__tests__/scenarios/data/air/plan-review-permission.jsonl +++ b/src/__tests__/scenarios/data/air/plan-review-permission.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","customInstructions","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","customInstructions","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"plan","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/plan-stream.jsonl b/src/__tests__/scenarios/data/air/plan-stream.jsonl index 66a79c3d8..3cfb5cf26 100644 --- a/src/__tests__/scenarios/data/air/plan-stream.jsonl +++ b/src/__tests__/scenarios/data/air/plan-stream.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","customInstructions","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","customInstructions","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/read-search-list.jsonl b/src/__tests__/scenarios/data/air/read-search-list.jsonl index 5a926020b..d0f9d1f39 100644 --- a/src/__tests__/scenarios/data/air/read-search-list.jsonl +++ b/src/__tests__/scenarios/data/air/read-search-list.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","customInstructions","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","customInstructions","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/subagent-activity.jsonl b/src/__tests__/scenarios/data/air/subagent-activity.jsonl index 96c9820c9..ae9ecc274 100644 --- a/src/__tests__/scenarios/data/air/subagent-activity.jsonl +++ b/src/__tests__/scenarios/data/air/subagent-activity.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","customInstructions","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","customInstructions","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/web-search.jsonl b/src/__tests__/scenarios/data/air/web-search.jsonl index 34ee899c6..7f204aab2 100644 --- a/src/__tests__/scenarios/data/air/web-search.jsonl +++ b/src/__tests__/scenarios/data/air/web-search.jsonl @@ -1,4 +1,4 @@ -{"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","customInstructions","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} +{"direction":"response","method":"initialize","params":{"_meta":{"archive":{"archiveMethod":"_session/archive","permanentDelete":false,"unarchiveMethod":"_session/unarchive","version":1},"discovery":{"attachmentActions":["list","add","remove"],"attachmentMethod":"_session/attachments","attachmentsAreMetadata":true,"searchMethod":"_session/search","version":1},"fileRevert":{"changesConversation":false,"coverage":"Recorded text patches only; not shell changes or a whole-workspace checkpoint","dryRun":true,"method":"_session/files/revert","previewTokenRequired":true,"requiresGit":true,"scope":"nativeFileChangeTool","version":1},"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","customInstructions","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta","codexHooks","sessionRewind"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"runtime":{"controlMethod":"_session/runtime/control","controls":["reloadSkills","reconnectMcp","reloadPlugins"],"readMethod":"_session/runtime/read","reads":["context","usage","mcp","commands","plugins"],"version":1},"steering":{"idleBehavior":["promptRequired"],"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/scenario-harness.ts b/src/__tests__/scenarios/scenario-harness.ts index fdbfe0bf2..06ccdb970 100644 --- a/src/__tests__/scenarios/scenario-harness.ts +++ b/src/__tests__/scenarios/scenario-harness.ts @@ -30,6 +30,7 @@ export const AIR_CAPABILITY_NAMES = [ "rawInputRendering", "planContentDelta", "codexHooks", + "sessionRewind", ]; /** The client capabilities of each client profile. */ @@ -97,7 +98,14 @@ export async function runScenario( } const messages = await runInWorkspace(replaceText(scenario, WS, workspace), profile, workspace); // A Git patch names the paths without the leading slash. - return replaceText(replaceText(messages, workspace, WS), workspace.slice(1), WS.slice(1)); + // Native locations use platform separators; Git patch headers always use '/'. + const portableWorkspace = workspace.replaceAll("\\", "/"); + const nativePaths = replaceText(replaceText(messages, workspace, WS), portableWorkspace, WS); + const local = portableWorkspace.startsWith("/") + ? replaceText(nativePaths, portableWorkspace.slice(1), WS.slice(1)) + : nativePaths; + return JSON.parse(JSON.stringify(local, (_key, value) => typeof value === "string" + ? value.replaceAll("/workspace\\", "/workspace/").replaceAll("/workspace/src\\", "/workspace/src/").replaceAll("a//workspace/", "a/workspace/").replaceAll("b//workspace/", "b/workspace/").replaceAll("from /workspace/", "from workspace/").replaceAll("to /workspace/", "to workspace/") : value)); } finally { fs.rmSync(workspace, {recursive: true, force: true}); } diff --git a/src/index.ts b/src/index.ts index 8fb26044b..ee68b8d48 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,26 +1,35 @@ #!/usr/bin/env node - -import * as acp from "@agentclientprotocol/sdk"; -import {z} from "zod"; -import {startCodexConnection} from "./CodexJsonRpcConnection"; -import {CodexAcpServer, type CodexProcessState} from "./CodexAcpServer"; -import {createJsonStream} from "./StdUtils"; -import {isCodexAuthRequest} from "./CodexAuthMethod"; -import {CodexAcpClient} from "./CodexAcpClient"; -import {CodexAppServerClient} from "./CodexAppServerClient"; -import packageJson from "../package.json"; -import {logger} from "./Logger"; -import {CodexAppServerSupervisor} from "./app-server-recovery/CodexAppServerSupervisor"; -import {runLoginCommand} from "./login"; -import {runCodexCli} from "./CodexCli"; -import {prepareCodexHookConfig} from "./CodexHookConfig"; -import {CODEX_HOOKS_LIST_METHOD, CODEX_HOOKS_TRUST_METHOD} from "./CodexHookTrust"; -import { +import {runInternalProcessHelper} from "./CodexProcessHelpers"; +import type * as AcpTypes from "@agentclientprotocol/sdk"; +import type {CodexProcessState, CodexAcpServer as CodexAcpServerType} from "./CodexAcpServer"; + +await runInternalProcessHelper(); + +const {SESSION_FILE_REVERT_METHOD, sessionFileRevertParser} = await import("./SessionFileRevert"); +const {SESSION_QUEUE_METHOD, parseSessionQueueRequest} = await import("./SessionQueue"); +const {SESSION_SEARCH_METHOD, SESSION_ATTACHMENTS_METHOD, sessionSearchParser, sessionAttachmentsParser} = await import("./SessionDiscovery"); + +const acp = await import("@agentclientprotocol/sdk"); +const {z} = await import("zod"); +const {startCodexConnection} = await import("./CodexJsonRpcConnection"); +const {CodexAcpServer} = await import("./CodexAcpServer"); +const {createJsonStream} = await import("./StdUtils"); +const {isCodexAuthRequest} = await import("./CodexAuthMethod"); +const {CodexAcpClient} = await import("./CodexAcpClient"); +const {CodexAppServerClient} = await import("./CodexAppServerClient"); +const {default: packageJson} = await import("../package.json"); +const {logger} = await import("./Logger"); +const {runLoginCommand} = await import("./login"); +const {runCodexCli} = await import("./CodexCli"); +const {prepareCodexHookConfig} = await import("./CodexHookConfig"); +const {CODEX_HOOKS_LIST_METHOD, CODEX_HOOKS_TRUST_METHOD} = await import("./CodexHookTrust"); +const { GOAL_CONTROL_METHOD, LEGACY_SET_SESSION_MODEL_METHOD, SESSION_STEERING_METHOD, -} from "./AcpExtensions"; -import {ASYNC_TASK_STOP_METHOD} from "./async-tasks/AsyncTaskExtension"; -import { +} = await import("./AcpExtensions"); +const {ASYNC_TASK_STOP_METHOD} = await import("./async-tasks/AsyncTaskExtension"); +const {CodexAppServerSupervisor} = await import("./app-server-recovery/CodexAppServerSupervisor"); +const { SESSION_ARCHIVE_METHOD, SESSION_LIST_SUBSCRIBE_METHOD, SESSION_LIST_UNSUBSCRIBE_METHOD, @@ -29,7 +38,10 @@ import { sessionArchiveParamsParser, sessionListSubscriptionParamsParser, sessionRenameParamsParser, -} from "./SessionIndex"; +} = await import("./SessionIndex"); + +const {RUNTIME_READ_METHOD, RUNTIME_CONTROL_METHOD, runtimeReadParser, runtimeControlParser} = await import("./SessionRuntime"); +const {SESSION_REWIND_METHOD} = await import("./SessionRewind"); const emptyExtensionParamsParser = z.preprocess( (params) => params ?? {}, @@ -63,6 +75,17 @@ const asyncTaskStopParamsParser = z.object({ asyncTaskId: z.string().trim().min(1), }).passthrough(); +const sessionHistoryPointParser = z.object({ + messageId: z.string().trim().min(1), + messageFingerprint: z.string().regex(/^sha256:[0-9a-f]{64}$/), + messageOccurrence: z.number().int().positive(), +}); +const sessionRewindParamsParser = z.object({ + sessionId: z.string().trim().min(1), + beforeMessage: sessionHistoryPointParser, + resumeAtMessage: sessionHistoryPointParser.optional(), +}).passthrough(); + const hooksListParamsParser = z.object({cwd: z.string().trim().min(1)}); const hooksTrustParamsParser = z.object({ cwd: z.string().trim().min(1), @@ -133,7 +156,7 @@ function startAcpServer() { const acpJsonStream = createJsonStream(process.stdin, process.stdout); - function createAgent(connection: acp.AgentContext): CodexAcpServer { + function createAgent(connection: AcpTypes.AgentContext): CodexAcpServerType { const appServerClient = new CodexAppServerClient(codexProcessState.connection.connection); const codexClient = new CodexAcpClient(appServerClient, hookConfig.sessionConfig, modelProvider); return new CodexAcpServer( @@ -146,8 +169,8 @@ function startAcpServer() { ); } - let codexAcpServer: CodexAcpServer | null = null; - const getAgent = (): CodexAcpServer => { + let codexAcpServer: CodexAcpServerType | null = null; + const getAgent = (): CodexAcpServerType => { if (!codexAcpServer) { throw acp.RequestError.internalError("ACP agent is not connected"); } @@ -187,12 +210,21 @@ function startAcpServer() { .onRequest(LEGACY_SET_SESSION_MODEL_METHOD, legacySetSessionModelParamsParser, (ctx) => getAgent().extMethod(LEGACY_SET_SESSION_MODEL_METHOD, ctx.params)) .onRequest(SESSION_STEERING_METHOD, sessionSteerParamsParser, (ctx) => getAgent().extMethod(SESSION_STEERING_METHOD, ctx.params)) .onRequest(ASYNC_TASK_STOP_METHOD, asyncTaskStopParamsParser, (ctx) => getAgent().extMethod(ASYNC_TASK_STOP_METHOD, ctx.params)) + .onRequest(SESSION_FILE_REVERT_METHOD, sessionFileRevertParser, (ctx) => getAgent().revertFiles(ctx.params)) + .onRequest(SESSION_QUEUE_METHOD, z.unknown().transform(value => parseSessionQueueRequest(value)), (ctx) => getAgent().manageSessionQueue(ctx.params, ctx.signal)) + .onRequest(SESSION_SEARCH_METHOD, sessionSearchParser, (ctx) => getAgent().searchSessionHistory(ctx.params)) + .onRequest(SESSION_ATTACHMENTS_METHOD, sessionAttachmentsParser, (ctx) => getAgent().manageSessionAttachments(ctx.params)) + .onRequest(RUNTIME_READ_METHOD, runtimeReadParser, (ctx) => getAgent().readSessionRuntime(ctx.params, ctx.signal)) + .onRequest(RUNTIME_CONTROL_METHOD, runtimeControlParser, (ctx) => getAgent().controlSessionRuntime(ctx.params)) + .onRequest(SESSION_REWIND_METHOD, sessionRewindParamsParser, (ctx) => getAgent().extMethod(SESSION_REWIND_METHOD, ctx.params)) .onRequest(CODEX_HOOKS_LIST_METHOD, hooksListParamsParser, (ctx) => getAgent().listHooks(ctx.params.cwd)) .onRequest(CODEX_HOOKS_TRUST_METHOD, hooksTrustParamsParser, (ctx) => getAgent().trustHooks(ctx.params.cwd, ctx.params.hooks)) .onRequest(GOAL_CONTROL_METHOD, goalControlParamsParser, (ctx) => getAgent().extMethod(GOAL_CONTROL_METHOD, ctx.params)) - .onRequest(SESSION_RENAME_METHOD, sessionRenameParamsParser, (ctx) => getAgent().sessionIndex.rename(ctx.params)) - .onRequest(SESSION_ARCHIVE_METHOD, sessionArchiveParamsParser, (ctx) => getAgent().sessionIndex.setArchived(ctx.params, true)) - .onRequest(SESSION_UNARCHIVE_METHOD, sessionArchiveParamsParser, (ctx) => getAgent().sessionIndex.setArchived(ctx.params, false)) + .onRequest(SESSION_RENAME_METHOD, sessionRenameParamsParser, (ctx) => getAgent().renameSession(ctx.params)) + .onRequest(SESSION_ARCHIVE_METHOD, sessionArchiveParamsParser, (ctx): Promise> => getAgent().sessionIndex.enabled + ? getAgent().setSessionArchived(ctx.params, true) : getAgent().archiveSession(ctx.params.sessionId, true)) + .onRequest(SESSION_UNARCHIVE_METHOD, sessionArchiveParamsParser, (ctx): Promise> => getAgent().sessionIndex.enabled + ? getAgent().setSessionArchived(ctx.params, false) : getAgent().archiveSession(ctx.params.sessionId, false)) .onRequest(SESSION_LIST_SUBSCRIBE_METHOD, sessionListSubscriptionParamsParser, (ctx) => getAgent().sessionIndex.subscribeList(ctx.params)) .onRequest(SESSION_LIST_UNSUBSCRIBE_METHOD, sessionListSubscriptionParamsParser, (ctx) => getAgent().sessionIndex.unsubscribeList(ctx.params)) .connect(acpJsonStream); diff --git a/src/subagents/CodexSubagentSubscriptions.ts b/src/subagents/CodexSubagentSubscriptions.ts index 27998e086..9491f456f 100644 --- a/src/subagents/CodexSubagentSubscriptions.ts +++ b/src/subagents/CodexSubagentSubscriptions.ts @@ -6,20 +6,28 @@ import type { import type {ServerNotification} from "../app-server"; import {isRootAgentPath} from "./CodexAgentPath"; -type Subscription = { +export type Subscription = { rootSessionId: string; supportsSubagents: boolean; - dispatch(event: ServerNotification): void; - enqueueInteraction(event: ServerNotification): void; + dispatch(event: ServerNotification): void | Promise; + enqueueInteraction(event: ServerNotification): void | Promise; approvalHandler: ApprovalHandler; elicitationHandler: ElicitationHandler; waitForRootNotifications(): Promise; waitForChildSession(childThreadId: string): Promise; + captureChild?: ((childThreadId: string) => Subscription) | undefined; }; type SessionSubscription = { current: Subscription; - children: Set; + children: Map; +}; + +type ChildSubscription = { + turnId: string | null; + ended: boolean; + retiredTurnIds: Set; + reused: boolean; }; /** Discovers child threads and keeps their output/interaction boundary negotiated. */ @@ -35,9 +43,10 @@ export class CodexSubagentSubscriptions { return; } - const session = {current: subscription, children: new Set()}; + const session: SessionSubscription = {current: subscription, children: new Map()}; this.sessions.set(subscription.rootSessionId, session); this.client.onServerNotification(subscription.rootSessionId, (event) => { + if (this.sessions.get(subscription.rootSessionId) !== session) return; // Register synchronously: app-server may emit child output directly // after the spawning collaboration item. this.discover(session, event); @@ -47,66 +56,103 @@ export class CodexSubagentSubscriptions { } clear(rootSessionId: string): void { - for (const childSessionId of this.sessions.get(rootSessionId)?.children ?? []) { + for (const childSessionId of this.sessions.get(rootSessionId)?.children.keys() ?? []) { this.client.clearThreadHandlers(childSessionId); } this.sessions.delete(rootSessionId); } - private discover(session: SessionSubscription, event: ServerNotification): void { + private discover(session: SessionSubscription, event: ServerNotification, parent?: Subscription): void { if (event.method !== "item/started" && event.method !== "item/completed") { return; } const item = event.params.item; - const childSessionIds = item.type === "collabAgentToolCall" && item.tool === "spawnAgent" + const resumesChild = item.type === "collabAgentToolCall" + && ["resumeAgent", "sendInput", "followupTask"].includes(item.tool); + const childSessionIds = item.type === "collabAgentToolCall" && (item.tool === "spawnAgent" || resumesChild) ? item.receiverThreadIds : item.type === "subAgentActivity" && item.kind !== "interrupted" && !isRootAgentPath(item.agentPath) ? [item.agentThreadId] : []; for (const childSessionId of childSessionIds) { if (childSessionId.trim() === "") continue; + const previous = session.children.get(childSessionId); if (childSessionId === session.current.rootSessionId || childSessionId === event.params.threadId - || session.children.has(childSessionId)) { + || (previous && !(resumesChild && previous.ended))) { continue; } - session.children.add(childSessionId); + const child: ChildSubscription = { + turnId: null, ended: false, reused: previous !== undefined, + retiredTurnIds: new Set(previous?.retiredTurnIds), + }; + if (previous?.turnId) child.retiredTurnIds.add(previous.turnId); + session.children.set(childSessionId, child); + // A child keeps the turn's event/permission context even after the root + // starts another queued turn. Do not resolve it from session.current later. + const owner = parent?.captureChild?.(childSessionId) ?? parent ?? session.current.captureChild?.(childSessionId); this.client.onServerNotification(childSessionId, (childEvent) => { + if (this.sessions.get(session.current.rootSessionId) !== session + || session.children.get(childSessionId) !== child) return; const eventThreadId = (childEvent.params as {threadId?: unknown}).threadId; if (eventThreadId !== childSessionId) return; - this.discover(session, childEvent); - if (session.current.supportsSubagents) session.current.dispatch(childEvent); - else session.current.enqueueInteraction(this.rootAttributed(childEvent, session.current.rootSessionId)); + const turnId = childEvent.method === "turn/started" || childEvent.method === "turn/completed" + ? childEvent.params.turn.id : (childEvent.params as {turnId?: string}).turnId; + if (turnId && child.retiredTurnIds.has(turnId)) return; + if (childEvent.method === "turn/started") { + if (child.turnId !== turnId && child.turnId !== null) child.retiredTurnIds.add(child.turnId); + child.turnId = childEvent.params.turn.id; + child.ended = false; + } else if (turnId && child.turnId !== null && turnId !== child.turnId) return; + if (childEvent.method === "turn/completed") { + child.turnId = childEvent.params.turn.id; + child.ended = true; + child.retiredTurnIds.add(childEvent.params.turn.id); + } + this.discover(session, childEvent, owner); + const current = owner ?? session.current; + if (current.supportsSubagents) current.dispatch(childEvent); + else current.enqueueInteraction(owner ? childEvent : this.rootAttributed(childEvent, current.rootSessionId)); }); // Hidden children keep only root-attributed permission requests. - this.registerInteractiveHandlers(session, childSessionId); + this.registerInteractiveHandlers(session, childSessionId, owner, child); } } - private registerInteractiveHandlers(session: SessionSubscription, targetSessionId: string): void { + private registerInteractiveHandlers(session: SessionSubscription, targetSessionId: string, owner?: Subscription, child?: ChildSubscription): void { + const resolveSession = async (current: Subscription, turnId?: string | null): Promise => { + // Capture even uncorrelated elicitations before waiting: they must not + // cross a child turn or subscription replacement while materializing. + const requestedTurn = child?.turnId; + const isCurrent = () => this.sessions.get(current.rootSessionId) === session + && (!child || (session.children.get(targetSessionId) === child && !child.ended + && (turnId == null ? child.turnId === requestedTurn : child.turnId === turnId + || (child.turnId === null && !child.reused && !child.retiredTurnIds.has(turnId))))); + await current.waitForRootNotifications(); + if (!isCurrent()) return null; + const sessionId = await this.interactionSessionId(current, targetSessionId); + return isCurrent() ? sessionId : null; + }; this.client.onApprovalRequest(targetSessionId, { handleCommandExecution: async (params) => { - const current = session.current; - await current.waitForRootNotifications(); - const sessionId = await this.interactionSessionId(current, targetSessionId); + const current = owner ?? session.current; + const sessionId = await resolveSession(current, params.turnId); if (sessionId === null) return {decision: "cancel"}; return await current.approvalHandler.handleCommandExecution( {...params, threadId: sessionId}, ); }, handleFileChange: async (params) => { - const current = session.current; - await current.waitForRootNotifications(); - const sessionId = await this.interactionSessionId(current, targetSessionId); + const current = owner ?? session.current; + const sessionId = await resolveSession(current, params.turnId); if (sessionId === null) return {decision: "cancel"}; return await current.approvalHandler.handleFileChange( {...params, threadId: sessionId}, ); }, handlePermissionsRequest: async (params) => { - const current = session.current; - await current.waitForRootNotifications(); - const sessionId = await this.interactionSessionId(current, targetSessionId); + const current = owner ?? session.current; + const sessionId = await resolveSession(current, params.turnId); if (sessionId === null) return {permissions: {}, scope: "turn", strictAutoReview: false}; return await current.approvalHandler.handlePermissionsRequest( {...params, threadId: sessionId}, @@ -115,18 +161,16 @@ export class CodexSubagentSubscriptions { }); this.client.onElicitationRequest(targetSessionId, { handleElicitation: async (params) => { - const current = session.current; - await current.waitForRootNotifications(); - const sessionId = await this.interactionSessionId(current, targetSessionId); + const current = owner ?? session.current; + const sessionId = await resolveSession(current, params.turnId); if (sessionId === null) return {action: "cancel", content: null, _meta: null}; return await current.elicitationHandler.handleElicitation( {...params, threadId: sessionId}, ); }, handleUserInput: async (params) => { - const current = session.current; - await current.waitForRootNotifications(); - const sessionId = await this.interactionSessionId(current, targetSessionId); + const current = owner ?? session.current; + const sessionId = await resolveSession(current, params.turnId); if (sessionId === null) return {answers: {}}; return await current.elicitationHandler.handleUserInput( {...params, threadId: sessionId}, diff --git a/src/tool-calls/reporters/FuzzySearchReporter.ts b/src/tool-calls/reporters/FuzzySearchReporter.ts index ec03b2114..19f4d34aa 100644 --- a/src/tool-calls/reporters/FuzzySearchReporter.ts +++ b/src/tool-calls/reporters/FuzzySearchReporter.ts @@ -19,7 +19,7 @@ export class FuzzySearchReporter { report: started ? "start" : "update", title: searchTitle(event.query, null), status: "in_progress", - locations: event.files.map(file => path.isAbsolute(file.path) ? file.path : path.join(file.root, file.path)), + locations: event.files.map(file => path.isAbsolute(file.path) ? file.path : (file.root.startsWith("/") ? path.posix : path).join(file.root, file.path)), }; return started ? {...facts, kind: "search", input: {query: event.query}} : facts; }