diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fc655efd6..b89f0a1fb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,3 +29,26 @@ jobs: run: npm test - name: Bundle binaries run: npm run bundle:all + + native-rewind: + name: Native rewind (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + permissions: + contents: read + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: "24" + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + - name: Install dependencies including the native binary + run: npm ci --include=optional + - name: Run isolated native conversation rewind + run: npm run test:native:rewind diff --git a/README.md b/README.md index 0c4ba6678..b87caed3c 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ Use [OpenAI Codex](https://github.com/openai/codex) from [Agent Client Protocol] - Typed warnings and errors through the opt-in [AIR session failure extension](docs/air-extensions.md#session-failure). - All AIR extensions, capabilities, and `_meta` keys: [AIR extensions](docs/air-extensions.md). - A per-turn [agent file-change report](docs/air-extensions.md#agent-file-change-report) after capability negotiation. +- In-place message editing through the AIR [session rewind extension](docs/session-rewind-extension.md), without a provider fork. - Client-provided MCP servers over command-based stdio config and HTTP transport. - Slash commands: `/status`, `/mcp` (with `/mcp reconnect`), `/skills`, `/goal`, `/review`, `/review-branch`, `/review-commit`, `/compact`, and `/logout`, as well as configured skills. diff --git a/docs/session-rewind-extension.md b/docs/session-rewind-extension.md new file mode 100644 index 000000000..a2c731f1b --- /dev/null +++ b/docs/session-rewind-extension.md @@ -0,0 +1,98 @@ +# 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 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. + +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 + +With Node.js 24+, Python 3 and the platform optional Codex dependency installed, +run `npm run test:native:rewind`. The runner builds this checkout, hashes source, +bundle and the resolved native executable, then launches real ACP/native +processes against a scripted loopback Responses provider. It requires no account +or external model. Only rewind/history/lifecycle scenarios are present; file +restore and other extension scenarios are excluded. + +The suite checks historical, latest and first-message rewind, same-ID cold +restart before resend, invalid and stale targets, exact repeated-message +identity, active-turn interruption, explicit cancellation, retained provider +context, native process identity and clean shutdown. No native transcript is +seeded or rewritten by the test client. The Windows/Linux native CI job runs +this independently of the upstream unit/build job. + +Each run creates an isolated home, workspace, config and temporary directory +under `tmp/native-e2e/run-*`. The child environment excludes inherited +credentials, provider overrides and user profiles. Model traffic is loopback; +an HTTP(S) deny proxy blocks incidental external requests, but is not an OS +network firewall. Artifact directories are retained for diagnosis, including +`build.json`, `report.json`, RPC/model traces and process-exit evidence. +Nonzero exit, missing acknowledgement, changed source/build, failed assertion +or unclean shutdown fails the run. + +Run `node scripts/native-e2e.mjs --rewind-only --wrong-reply` to verify the +failure path: it must exit 1 and record `success:false`. The optional +`--output ` selects an artifact parent inside this checkout only. diff --git a/package.json b/package.json index 297f0a972..08943f2b1 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,8 @@ "test:e2e": "npm run build && RUN_E2E_TESTS=true vitest run --no-file-parallelism --retry=2 src/__tests__/CodexACPAgent/e2e", "test:watch": "vitest", "typecheck": "tsc --noEmit && tsc --noEmit -p examples/tsconfig.json", - "codex-test": "tsx .claude/skills/run-codex/scripts/run-codex-test.ts" + "codex-test": "tsx .claude/skills/run-codex/scripts/run-codex-test.ts", + "test:native:rewind": "node scripts/native-e2e.mjs --rewind-only" }, "homepage": "https://github.com/agentclientprotocol/codex-acp#readme", "bugs": { diff --git a/readme-dev.md b/readme-dev.md index 458c263fc..1c17e42a5 100644 --- a/readme-dev.md +++ b/readme-dev.md @@ -105,6 +105,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` 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/native-e2e.mjs b/scripts/native-e2e.mjs new file mode 100644 index 000000000..70b9399ec --- /dev/null +++ b/scripts/native-e2e.mjs @@ -0,0 +1,102 @@ +// No account, external provider, globally installed Codex, or saved fixture is used. +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repo = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const args = process.argv.slice(2); +let output, rewindOnly = false, wrongReply = false; +for (let i = 0; i < args.length; i++) { + if (args[i] === '--rewind-only' && !rewindOnly) rewindOnly = true; + else if (args[i] === '--wrong-reply' && !wrongReply) wrongReply = true; + else if (args[i] === '--output' && !output && args[i + 1] && !args[i + 1].startsWith('--')) output = args[++i]; + else { + console.error('Usage: npm run test:native:rewind -- [--rewind-only] [--wrong-reply] [--output ]'); + process.exit(2); + } +} +if (Number(process.versions.node.split('.')[0]) < 24) throw new Error('Node 24+ is required'); +const parent = path.resolve(output ?? path.join(repo, 'tmp', 'native-e2e')); +const relativeOutput = path.relative(repo, parent); +if (relativeOutput.startsWith('..') || path.isAbsolute(relativeOutput)) throw new Error('Artifact output must stay inside this checkout'); +mkdirSync(parent, { recursive: true }); +const run = mkdtempSync(path.join(parent, 'run-')); +for (const name of ['home', 'workspace', 'tmp', 'home/appdata', 'home/localappdata', 'home/.config', 'home/.cache']) { + mkdirSync(path.join(run, name), { recursive: true }); +} +// A whitelist deliberately excludes credentials, proxies, NODE_OPTIONS, Git +// overrides, Codex configuration, and the invoking user's profile directories. +const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => + ['PATH', 'SYSTEMROOT', 'WINDIR', 'COMSPEC', 'PATHEXT'].includes(key.toUpperCase()))); +Object.assign(env, { + HOME: path.join(run, 'home'), USERPROFILE: path.join(run, 'home'), + CODEX_HOME: path.join(run, 'home'), APPDATA: path.join(run, 'home/appdata'), + LOCALAPPDATA: path.join(run, 'home/localappdata'), + XDG_CONFIG_HOME: path.join(run, 'home/.config'), XDG_CACHE_HOME: path.join(run, 'home/.cache'), + TMP: path.join(run, 'tmp'), TEMP: path.join(run, 'tmp'), TMPDIR: path.join(run, 'tmp'), + GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: path.join(run, 'home/.gitconfig'), + GIT_OPTIONAL_LOCKS: '0', +}); +writeFileSync(env.GIT_CONFIG_GLOBAL, ''); +const hash = (file) => createHash('sha256').update(readFileSync(file)).digest('hex'); +function walk(dir) { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const file = path.join(dir, entry.name); + return entry.isDirectory() ? walk(file) : [file]; + }).sort(); +} +function sources() { + return Object.fromEntries([...walk(path.join(repo, 'src')), + ...['package.json', 'package-lock.json', 'build.mjs'].map((name) => path.join(repo, name))] + .map((file) => [path.relative(repo, file), hash(file)])); +} +function command(binary, argv) { + const result = spawnSync(binary, argv, { + cwd: repo, env, windowsHide: true, encoding: 'utf8', timeout: 120_000, + }); + if (result.error || result.status !== 0) { + throw new Error(`${binary} failed: ${result.error ?? result.stderr ?? result.status}`); + } + return result.stdout.trim(); +} +try { + const require = createRequire(path.join(repo, 'package.json')); + const codexManifest = require.resolve('@openai/codex/package.json'); + const installed = JSON.parse(readFileSync(codexManifest, 'utf8')); + const packageName = `@openai/codex-${process.platform}-${process.arch}`; + if (!installed.optionalDependencies?.[packageName]) throw new Error(`Unsupported native platform: ${packageName}`); + const nativeManifest = createRequire(codexManifest).resolve(`${packageName}/package.json`); + // Discover the actual installed layout; do not assume a vendor triple or bin/codex subdirectory. + const candidates = walk(path.join(path.dirname(nativeManifest), 'vendor')) + .filter((file) => path.basename(file) === (process.platform === 'win32' ? 'codex.exe' : 'codex')); + if (candidates.length !== 1) throw new Error(`Expected one installed native executable, found ${candidates.length}`); + const native = realpathSync(candidates[0]); + const before = sources(); + writeFileSync(path.join(run, 'build.log'), command(process.execPath, [path.join(repo, 'build.mjs')])); + if (JSON.stringify(before) !== JSON.stringify(sources())) throw new Error('Sources changed during build; rerun after concurrent edits finish'); + const metadata = { + repo, run, mode: 'rewind-only', wrongReply, node: process.execPath, nodeVersion: process.version, native, + nativePackage: packageName, codexVersion: installed.version, + nativeVersion: command(native, ['--version']), nativeSha256: hash(native), + distSha256: hash(path.join(repo, 'dist/index.js')), sources: before, + }; + const manifest = path.join(run, 'build.json'); + writeFileSync(manifest, JSON.stringify(metadata, null, 2) + '\n'); + const python = process.env.NATIVE_E2E_PYTHON || (process.platform === 'win32' ? 'python' : 'python3'); + console.log(`Native E2E artifacts: ${run}`); + const result = spawnSync(python, [path.join(repo, 'scripts/native-e2e/probe.py'), manifest], { + // Python bounds each RPC/child shutdown and cleans up in finally. Killing + // just Python on a launcher timeout would orphan its owned ACP/native tree. + cwd: repo, env, windowsHide: true, stdio: 'inherit', + }); + if (result.error) throw result.error; + process.exitCode = result.status ?? 1; +} catch (error) { + writeFileSync(path.join(run, 'launcher-failure.txt'), String(error.stack ?? error)); + console.error(error); + console.error(`Native E2E artifacts: ${run}`); + process.exitCode = 1; +} diff --git a/scripts/native-e2e/probe.py b/scripts/native-e2e/probe.py new file mode 100644 index 000000000..89987de96 --- /dev/null +++ b/scripts/native-e2e/probe.py @@ -0,0 +1,535 @@ +"""Real ACP/native processes over stdio, with a scripted loopback Responses server. + +Only the model is fake. Never read native rollout files, write ACP history, or +execute file tools in the test client. Invoke through ../native-e2e.mjs so every +run is fresh and bound to a just-built artifact. Python 3 standard library only. +""" +import gzip +import hashlib +import http.server +import json +import os +from pathlib import Path +import queue +import shutil +import signal +import subprocess +import sys +import threading +import time +import traceback + + +TIMEOUT = 35 +FLAGS = getattr(subprocess, "CREATE_NO_WINDOW", 0) + + +def require(condition, message): + # Do not use assert: python -O must still fail a broken native test. + if not condition: + raise AssertionError(message) + + +def sha(file): + return hashlib.sha256(Path(file).read_bytes()).hexdigest() + + +def save(file, value): + Path(file).write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf8") + + +def points(updates, kind="user_message_chunk"): + grouped = {} + for update in updates: + if update.get("sessionUpdate") != kind: + continue + mid = update.get("messageId") + content = update.get("content", {}) + require(mid and content.get("type") == "text", f"Missing native text identity: {update}") + grouped[mid] = grouped.get(mid, "") + content["text"] + seen, result = {}, [] + for mid, text in grouped.items(): + fingerprint = "sha256:" + hashlib.sha256(text.encode()).hexdigest() + seen[fingerprint] = seen.get(fingerprint, 0) + 1 + result.append({"messageId": mid, "messageFingerprint": fingerprint, + "messageOccurrence": seen[fingerprint], "text": text}) + return result + + +def point(value): + return {key: val for key, val in value.items() if key != "text"} + + +class Model: + def __init__(self, wrong_reply=False): + self.requests, self.title_requests, self.errors, self.blocked = [], [], [], [] + self.wrong_reply, self.injected, self.expected_answers = wrong_reply, False, [] + self.pending = queue.Queue() + self.seen, self.release = threading.Event(), threading.Event() + self.lock = threading.Lock() + owner = self + + class Deny(http.server.BaseHTTPRequestHandler): + def log_message(self, *_): + pass + + def reject(self): + owner.blocked.append({"method": self.command, "target": self.path}) + self.send_error(502, "Native E2E denies external HTTP egress") + + do_CONNECT = do_GET = do_POST = reject + + class Responses(http.server.BaseHTTPRequestHandler): + def log_message(self, *_): + pass + + def do_POST(self): + try: + require(self.path == "/v1/responses", f"Unexpected model path: {self.path}") + raw = self.rfile.read(int(self.headers.get("Content-Length", "0"))) + if self.headers.get("Content-Encoding") == "gzip": + raw = gzip.decompress(raw) + body = json.loads(raw) + require("Authorization" not in self.headers, "Unexpected model credentials") + # The adapter also makes ephemeral title turns. Recognize the + # actual schema AND title instruction, never just a model name + # or a sentinel that could appear in discarded user history. + schema = body.get("text", {}).get("format", {}).get("schema", {}) + title = (schema.get("required") == ["title"] + and schema.get("properties") == {"title": {"type": "string"}} + and "Your task is to generate a very short title for a conversation" in json.dumps(body.get("input", []))) + with owner.lock: + target = owner.title_requests if title else owner.requests + target.append({"path": self.path, "body": body}) + number = len(owner.requests) + len(owner.title_requests) + if title: + require(len(owner.title_requests) <= 8, "Unexpected title request loop") + action = "title" + else: + try: + action = owner.pending.get_nowait() + except queue.Empty: + raise AssertionError("Unsolicited model request (rewind/load must not resend)") + if action == "hold": + owner.seen.set() + require(owner.release.wait(TIMEOUT), "Held request was not released") + events = [{"type": "response.created", "response": {"id": f"resp-{number}"}}] + answer = '{"title":"Local native fixture"}' if title else f"LOCAL_ANSWER_{number}" + if not title: + owner.expected_answers.append(answer) + if owner.wrong_reply and not owner.injected: + answer = "INJECTED_WRONG_REPLY" + owner.injected = True + events.append({"type": "response.output_item.done", "item": { + "type": "message", "role": "assistant", "id": f"msg-{number}", + "content": [{"type": "output_text", "text": answer}]}}) + events.append({"type": "response.completed", "response": { + "id": f"resp-{number}", "usage": {"input_tokens": 10, "output_tokens": 2, "total_tokens": 12}}}) + data = "".join("data: " + json.dumps(event) + "\n\n" for event in events).encode() + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + try: + self.wfile.write(data) + except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError): + pass # Expected after cancellation of the held native turn. + except Exception as error: + owner.errors.append(str(error)) + self.send_error(500, "Local fixture failed; see report.json") + + self.proxy = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Deny) + self.server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Responses) + for server in (self.proxy, self.server): + threading.Thread(target=server.serve_forever, daemon=True).start() + + def arm(self, *actions): + require(self.pending.empty(), "Previous prompt did not consume its expected model calls") + for action in actions: + self.pending.put(action) + + def check(self): + require(not self.errors, f"Local model errors: {self.errors}") + require(self.pending.empty(), "Missing expected model call") + + def close(self): + self.release.set() + for server in (self.proxy, self.server): + server.shutdown() + server.server_close() + + +class Rpc: + def __init__(self, probe): + self.probe = probe + self.seq, self.stash, self.trace = 0, {}, [] + self.incoming = queue.Queue() + self.closed = False + self.number = len(probe.clients) + probe.clients.append(self) + self.stderr = (probe.run / f"stderr-{self.number}.log").open("w", encoding="utf8") + self.p = subprocess.Popen( + [probe.meta["node"], str(probe.repo / "dist/index.js")], cwd=probe.work, env=probe.env, + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=self.stderr, + text=True, encoding="utf8", creationflags=FLAGS, start_new_session=os.name != "nt") + self.reader_thread = threading.Thread(target=self.reader, daemon=True) + self.reader_thread.start() + self.init = self.call("initialize", { + "protocolVersion": 1, "clientInfo": {"name": "isolated-native-e2e", "version": "1"}, + "clientCapabilities": {"_meta": {"jetbrains": {"air": { + "version": 1, "capabilities": []}}}}}) + + def reader(self): + try: + for line in self.p.stdout: + try: + message = json.loads(line) + except json.JSONDecodeError: + self.incoming.put(RuntimeError(f"Non-JSON ACP stdout: {line[:200]}")) + continue + self.trace.append({"direction": "in", "message": message}) + self.incoming.put(message) + finally: + self.incoming.put(RuntimeError("ACP stdout closed before requested response")) + + def write(self, message): + self.trace.append({"direction": "out", "message": message}) + self.p.stdin.write(json.dumps(message) + "\n") + self.p.stdin.flush() + + def send(self, method, params): + self.seq += 1 + self.write({"jsonrpc": "2.0", "id": self.seq, "method": method, "params": params}) + return self.seq + + def notify(self, method, params): + self.write({"jsonrpc": "2.0", "method": method, "params": params}) + + def response(self, ident): + if ident in self.stash: + return self.stash.pop(ident) + deadline = time.monotonic() + TIMEOUT + while True: + try: + message = self.incoming.get(timeout=max(0.01, deadline - time.monotonic())) + except queue.Empty: + raise TimeoutError(f"ACP response {ident} timed out; inspect stderr-{self.number}.log") + if isinstance(message, Exception): + raise message + if "id" in message and "method" in message: + # Never emulate native apply_patch or other host tools. + self.write({"jsonrpc": "2.0", "id": message["id"], "error": { + "code": -32601, "message": "Native E2E client tools disabled"}}) + self.probe.client_requests.append(message) + elif "id" in message: + if message["id"] == ident: + return message + self.stash[message["id"]] = message + require(time.monotonic() < deadline, f"ACP response {ident} timed out") + + def raw(self, method, params): + return self.response(self.send(method, params)) + + def call(self, method, params): + message = self.raw(method, params) + require("error" not in message, f"{method}: {message}") + return message["result"] + + def new(self): + return self.call("session/new", {"cwd": str(self.probe.work), "mcpServers": []})["sessionId"] + + def prompt(self, sid, text): + self.probe.model.arm("answer") + result = self.call("session/prompt", {"sessionId": sid, "prompt": [{"type": "text", "text": text}]}) + require(result.get("stopReason") == "end_turn", result) + self.probe.model.check() + # The scripted SSE emits a completed item rather than text deltas. + # Verify the persisted native answer through the real ACP replay route. + answers = points(self.load(sid), "agent_message_chunk") + actual = answers[-1]["text"] if answers else "" + expected = self.probe.model.expected_answers[-1] + require(actual == expected, f"Assistant reply mismatch: expected {expected!r}, received {actual!r}") + return result + + def load(self, sid): + start = len(self.trace) + count = len(self.probe.model.requests) + result = self.call("session/load", {"sessionId": sid, "cwd": str(self.probe.work), "mcpServers": []}) + updates = [entry["message"]["params"] for entry in self.trace[start:] + if entry["direction"] == "in" and entry["message"].get("method") == "session/update"] + require(all(value["sessionId"] == sid for value in updates), "Load changed session ID") + require(result.get("sessionId", sid) == sid, "Load returned another session ID") + require(len(self.probe.model.requests) == count, "Load resent a prompt") + self.probe.model.check() + return [value["update"] for value in updates] + + def rewind(self, sid, before, resume=None): + request = {"sessionId": sid, "beforeMessage": point(before)} + if resume: + request["resumeAtMessage"] = point(resume) + count = len(self.probe.model.requests) + result = self.call("_session/rewind", request) + require(result.get("rewound") is True, result) + require(result.get("sessionId", sid) == sid, result) + require(len(self.probe.model.requests) == count, "Rewind resent a prompt") + return result + + def stop(self): + if self.closed: + return + self.closed = True + forced = False + try: + self.p.stdin.close() + try: + self.p.wait(timeout=8) + except subprocess.TimeoutExpired: + forced = True + if os.name == "nt": + subprocess.run(["taskkill", "/PID", str(self.p.pid), "/T", "/F"], + env=self.probe.env, capture_output=True, timeout=6, creationflags=FLAGS) + else: + os.killpg(self.p.pid, signal.SIGKILL) + self.p.wait(timeout=5) + self.reader_thread.join(timeout=2) + finally: + self.stderr.close() + self.probe.exits.append({"client": self.number, "pid": self.p.pid, + "exitCode": self.p.returncode, "forced": forced}) + require(not forced and self.p.returncode == 0, f"ACP did not shut down cleanly: {self.probe.exits[-1]}") + # Confirm the old provider exited before opening a replacement ACP + # process. A later final check alone could miss overlapping providers. + entries = [json.loads(line) for line in (self.probe.run / "spawns.jsonl").read_text(encoding="utf8").splitlines()] + owned = {self.p.pid} + while True: + children = {entry.get("pid") for entry in entries + if entry["event"] == "spawn" and entry.get("parentPid") in owned} + expanded = owned | (children - {None}) + if expanded == owned: + break + owned = expanded + native = [entry for entry in entries if entry["event"] == "spawn" and entry.get("pid") in owned + and Path(entry["file"]).resolve() == Path(self.probe.meta["native"]).resolve()] + exited = {entry["pid"] for entry in entries if entry["event"] == "exit"} + require(len(native) == 1 and native[0]["pid"] in exited, "Owned native must exit before cold restart") + + +class Probe: + def __init__(self, manifest): + self.meta = json.loads(manifest.read_text(encoding="utf8")) + self.repo, self.run = Path(self.meta["repo"]), Path(self.meta["run"]) + self.home, self.work = self.run / "home", self.run / "workspace" + self.clients, self.exits, self.steps, self.client_requests = [], [], [], [] + self.model = Model(self.meta.get("wrongReply", False)) + self.env = dict(os.environ) # Launcher already replaced, not merged, the user's environment. + # Narrow runtime PATH to required programs and OS utilities. + self.git = shutil.which("git") + paths = [str(Path(self.meta["node"]).parent)] + if self.git: + paths.append(str(Path(self.git).parent)) + paths += [str(Path(os.environ["SYSTEMROOT"]) / "System32")] if os.name == "nt" else ["/usr/bin", "/bin"] + self.env["PATH"] = os.pathsep.join(dict.fromkeys(paths)) + proxy = f"http://127.0.0.1:{self.model.proxy.server_port}" + for key in ("HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"): + self.env[key] = proxy + self.env.update(NO_PROXY="127.0.0.1,localhost,::1", no_proxy="127.0.0.1,localhost,::1") + provider = {"name": "Native E2E loopback", "base_url": f"http://127.0.0.1:{self.model.server.server_port}/v1", + "wire_api": "responses", "requires_openai_auth": False, + "request_max_retries": 0, "stream_max_retries": 0} + # Select installed model metadata; all responses come from loopback. + config = {"model": "gpt-5.5", "model_provider": "mock", "model_providers": {"mock": provider}, + "approval_policy": "never", "sandbox_mode": "danger-full-access", + "features": {"shell_snapshot": False, "remote_models": False, "plugins": False, "code_mode": False}} + self.env.update(CODEX_PATH=self.meta["native"], MODEL_PROVIDER="mock", CODEX_CONFIG=json.dumps(config), + INITIAL_AGENT_MODE="agent-full-access", NO_BROWSER="1", + NODE_OPTIONS='--require=' + json.dumps(str(self.repo / "scripts/native-e2e/spawn-audit.cjs")), + NATIVE_E2E_SPAWN_LOG=str(self.run / "spawns.jsonl")) + (self.home / "config.toml").write_text( + 'model = "gpt-5.5"\nmodel_provider = "mock"\napproval_policy = "never"\nsandbox_mode = "danger-full-access"\n' + '[features]\nshell_snapshot = false\nremote_models = false\nplugins = false\ncode_mode = false\n' + '[model_providers.mock]\n' + "\n".join(f"{key} = {json.dumps(value)}" for key, value in provider.items()) + "\n", + encoding="utf8") + self.rpc = None + + def step(self, name, **details): + self.steps.append({"name": name, "passed": True, **details}) + print("PASS " + name, flush=True) + + def start(self): + self.rpc = Rpc(self) + return self.rpc + + def restart(self): + self.rpc.stop() + self.verify_build() + return self.start() + + def verify_build(self): + require(sha(self.repo / "dist/index.js") == self.meta["distSha256"], "Build changed during probe; rerun") + require(sha(self.meta["native"]) == self.meta["nativeSha256"], "Native changed during probe; rerun") + current_names = {str(file.relative_to(self.repo)) for file in (self.repo / "src").rglob("*") if file.is_file()} + current_names.update(["package.json", "package-lock.json", "build.mjs"]) + require(current_names == set(self.meta["sources"]), "Source file set changed during probe; rerun") + for name, expected in self.meta["sources"].items(): + require(sha(self.repo / name) == expected, f"Source changed during probe: {name}; rerun") + + def context(self, present, absent=()): + body = json.dumps(self.model.requests[-1]["body"].get("input", [])) + require(all(text in body for text in present), f"Missing model context: {present}") + require(all(text not in body for text in absent), f"Discarded context sent to model: {absent}") + return body + + def history(self): + c = self.start() + capabilities = c.init.get("_meta", {}).get("jetbrains", {}).get("air", {}).get("capabilities", []) + require("sessionRewind" in capabilities, "AIR rewind capability not advertised") + sid = c.new() + for text in ("KEEP_FIRST", "DROP_SECOND", "DROP_THIRD"): + c.prompt(sid, text) + updates = c.load(sid) + users, agents = points(updates), points(updates, "agent_message_chunk") + require([u["text"] for u in users] == ["KEEP_FIRST", "DROP_SECOND", "DROP_THIRD"], users) + self.step("native_history_identities", sessionId=sid, users=users) + bad = {**point(users[1]), "messageFingerprint": "sha256:" + "0" * 64} + count = len(self.model.requests) + for request in ({"sessionId": sid, "beforeMessage": bad}, + {"sessionId": "unknown-session", "beforeMessage": point(users[1])}, + {"sessionId": sid, "beforeMessage": {**point(users[1]), "messageId": "missing-id", "messageFingerprint": bad["messageFingerprint"]}}): + result = c.raw("_session/rewind", request) + require("error" in result, result) + require(points(c.load(sid)) == users and len(self.model.requests) == count, "Bad target mutated history") + self.step("bad_targets_preserve_history_and_do_not_resend") + c.rewind(sid, users[1], agents[0]) + c = self.restart() + retained = points(c.load(sid)) + require([u["text"] for u in retained] == ["KEEP_FIRST"], retained) + require(len(self.model.requests) == count, "Historical cold restart resent prompt") + self.step("historical_same_id_cold_restart_no_resend", sessionId=sid, retained=retained) + c.prompt(sid, "EDITED_SECOND") + self.context(["KEEP_FIRST", "EDITED_SECOND"], ["DROP_SECOND", "DROP_THIRD"]) + self.step("historical_resend_model_context") + updates = c.load(sid) + users, agents = points(updates), points(updates, "agent_message_chunk") + count = len(self.model.requests) + stale_latest = point(users[-1]) + c.rewind(sid, users[-1], agents[-2]) + c = self.restart() + require([u["text"] for u in points(c.load(sid))] == ["KEEP_FIRST"] and len(self.model.requests) == count, + "Latest rewind/cold restart did not retain exactly the prefix without resend") + self.step("latest_same_id_cold_restart_no_resend", sessionId=sid) + stale = c.raw("_session/rewind", {"sessionId": sid, "beforeMessage": stale_latest}) + require("error" in stale, stale) + require([u["text"] for u in points(c.load(sid))] == ["KEEP_FIRST"] and len(self.model.requests) == count, + "Stale removed target mutated/resubmitted history") + self.step("stale_removed_target_rejected_after_cold_restart") + c.prompt(sid, "EDITED_LATEST") + self.context(["KEEP_FIRST", "EDITED_LATEST"], ["EDITED_SECOND", "DROP_SECOND", "DROP_THIRD"]) + self.step("latest_resend_model_context") + users = points(c.load(sid)) + count = len(self.model.requests) + c.rewind(sid, users[0]) + c = self.restart() + require(points(c.load(sid)) == [] and len(self.model.requests) == count, "First rewind retained/resubmitted history") + self.step("first_same_id_cold_restart_no_resend", sessionId=sid) + c.prompt(sid, "EDITED_FIRST") + self.context(["EDITED_FIRST"], ["KEEP_FIRST", "EDITED_SECOND", "EDITED_LATEST", "DROP_SECOND", "DROP_THIRD"]) + self.step("first_resend_model_context") + target = points(c.load(sid))[0] + self.model.seen.clear() + self.model.release.clear() + self.model.arm("hold") + prompt_id = c.send("session/prompt", {"sessionId": sid, "prompt": [{"type": "text", "text": "ACTIVE_TURN"}]}) + require(self.model.seen.wait(10), "Active prompt did not reach local model") + c.rewind(sid, target) + interrupted = c.response(prompt_id) + require(interrupted.get("result", {}).get("stopReason") == "cancelled", interrupted) + self.model.release.set() + self.model.check() + c = self.restart() + require(points(c.load(sid)) == [], "Busy rewind did not persist empty history") + c.prompt(sid, "AFTER_CANCEL") + self.context(["AFTER_CANCEL"], ["ACTIVE_TURN", "EDITED_FIRST"]) + self.step("active_rewind_cancels_native_turn_and_cold_resumes_same_id") + # Repeated text must use occurrence/native identity, not substring matching. + c.prompt(sid, "REPEATED_TARGET") + c.prompt(sid, "REPEATED_TARGET") + updates = c.load(sid) + users, agents = points(updates), points(updates, "agent_message_chunk") + c.rewind(sid, users[-1], agents[-2]) + c = self.restart() + require([u["text"] for u in points(c.load(sid))] == ["AFTER_CANCEL", "REPEATED_TARGET"], "Repeated target boundary lost") + c.prompt(sid, "REPLACED_DUPLICATE") + require(self.context(["AFTER_CANCEL", "REPEATED_TARGET", "REPLACED_DUPLICATE"]).count("REPEATED_TARGET") == 1, + "Duplicate target survived rewind") + self.step("duplicate_exact_identity_cold_restart_resend") + self.model.seen.clear() + self.model.release.clear() + self.model.arm("hold") + prompt_id = c.send("session/prompt", {"sessionId": sid, "prompt": [{"type": "text", "text": "EXPLICIT_CANCEL"}]}) + require(self.model.seen.wait(10), "Cancel fixture did not reach model") + c.notify("session/cancel", {"sessionId": sid}) + interrupted = c.response(prompt_id) + require(interrupted.get("result", {}).get("stopReason") == "cancelled", interrupted) + self.model.release.set() + c.prompt(sid, "AFTER_EXPLICIT_CANCEL") + self.context(["AFTER_EXPLICIT_CANCEL"]) + self.step("explicit_cancel_and_same_session_continue") + + def run_all(self): + report = {"success": False, "build": self.meta, "steps": self.steps} + try: + self.verify_build() + self.history() + self.rpc.stop() + self.verify_build() + self.model.check() + require(not self.client_requests, "Native unexpectedly requested host/client tools") + entries = [json.loads(line) for line in (self.run / "spawns.jsonl").read_text(encoding="utf8").splitlines()] + spawns = [entry for entry in entries if entry["event"] == "spawn"] + exits = {entry["pid"] for entry in entries if entry["event"] == "exit"} + native = [entry for entry in spawns if Path(entry["file"]).resolve() == Path(self.meta["native"]).resolve()] + require(len(native) == len(self.clients), "Each ACP process must launch the resolved real native binary once") + require(all(entry.get("pid") in exits for entry in native), "Native child did not exit before cold restart/cleanup") + if os.name == "nt": + require(all(entry.get("windowsHide") is True for entry in spawns), "Runtime spawn missing windowsHide: inspect spawns.jsonl") + require(not any(entry.get("shell") for entry in native), "Direct native executable must not use a shell") + self.step("native_identity_windowsHide_and_clean_process_shutdown", clients=len(self.clients), spawnCount=len(spawns)) + report["success"] = True + except Exception as error: + report["error"] = {"type": type(error).__name__, "message": str(error)} + (self.run / "failure.txt").write_text(traceback.format_exc(), encoding="utf8") + finally: + self.model.release.set() + for client in self.clients: + try: + client.stop() + except Exception as error: + report["success"] = False + report.setdefault("cleanupErrors", []).append(str(error)) + self.model.close() + try: + self.verify_build() + except Exception as error: + report["success"] = False + report["finalBuildError"] = str(error) + if self.model.errors or not self.model.pending.empty(): + report["success"] = False + report.setdefault("error", {"type": "ModelFixtureError", "message": "Late or missing model request; inspect providerErrors"}) + report.update(modelRequests=len(self.model.requests), titleRequests=len(self.model.title_requests), providerErrors=self.model.errors, + wrongReplyInjected=self.model.injected, + blockedExternalRequests=self.model.blocked, processExits=self.exits, + clientRequests=self.client_requests, + networkBoundary="Loopback provider and deny HTTP(S) proxy; not an OS egress firewall", + harnessSha256=sha(__file__)) + save(self.run / "rpc-traces.json", [client.trace for client in self.clients]) + save(self.run / "model-requests.json", self.model.requests) + save(self.run / "title-requests.json", self.model.title_requests) + save(self.run / "report.json", report) + print(json.dumps({"success": report["success"], "report": str(self.run / "report.json"), + "steps": len(self.steps), "error": report.get("error")}), flush=True) + return 0 if report["success"] else 1 + + +if __name__ == "__main__": + require(len(sys.argv) == 2, "Use npm run test:native:rewind (a fresh build manifest is required)") + raise SystemExit(Probe(Path(sys.argv[1])).run_all()) diff --git a/scripts/native-e2e/spawn-audit.cjs b/scripts/native-e2e/spawn-audit.cjs new file mode 100644 index 000000000..c7109e4e9 --- /dev/null +++ b/scripts/native-e2e/spawn-audit.cjs @@ -0,0 +1,13 @@ +// Observe runtime spawn options without changing them or replacing the process. +const cp = require('node:child_process'); +const { appendFileSync } = require('node:fs'); +const { syncBuiltinESMExports } = require('node:module'); +const spawn = cp.spawn; +const record = (entry) => appendFileSync(process.env.NATIVE_E2E_SPAWN_LOG, JSON.stringify(entry) + '\n'); +cp.spawn = function (file, args, options) { + const child = spawn.apply(this, arguments); + record({ event: 'spawn', file, args, parentPid: process.pid, pid: child.pid, windowsHide: options?.windowsHide, shell: options?.shell }); + child.once('exit', (code, signal) => record({ event: 'exit', pid: child.pid, code, signal })); + return child; +}; +syncBuiltinESMExports(); diff --git a/src/AcpExtensions.ts b/src/AcpExtensions.ts index b450c8bd0..cca4297fd 100644 --- a/src/AcpExtensions.ts +++ b/src/AcpExtensions.ts @@ -15,6 +15,10 @@ import { ASYNC_TASK_STOP_METHOD, type AsyncTaskStopExtRequest, } from "./async-tasks/AsyncTaskExtension"; +import { + SESSION_REWIND_METHOD, + type SessionRewindRequest, +} from "./SessionRewind"; export { AUTH_STATUS_META_KEY, @@ -79,6 +83,7 @@ export type ExtMethodRequest = | SessionSteeringExtRequest | GoalControlExtRequest | AsyncTaskStopExtRequest + | SessionRewindExtRequest export function isExtMethodRequest(request: { method: string, params: Record }): request is ExtMethodRequest { return request.method === "authentication/status" @@ -87,7 +92,8 @@ export function isExtMethodRequest(request: { method: string, params: Record, params: SessionSteerRequest, diff --git a/src/AirExtension.ts b/src/AirExtension.ts index 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..363ad0ef7 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -63,6 +63,7 @@ import type {ModeKind} from "./app-server/ModeKind"; import {arePathBasenamesEqual, arePathsEqual, isAbsolutePathLike} from "./PathUtils"; import {CodexSubagentSubscriptions} from "./subagents/CodexSubagentSubscriptions"; import {forkSession as runForkSession} from "./SessionFork"; +import {rewindSession as runRewindSession, type SessionRewindRequest} from "./SessionRewind"; import type {SessionMetadata, SessionMetadataWithThread} from "./SessionMetadata"; import { isMissingRolloutError, @@ -718,6 +719,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); diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index d3f90a8bb..102848753 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -1,7 +1,8 @@ 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 {CodexApprovalHandler} from "./permissions/CodexApprovalHandler"; import {PermissionLifecycleContext} from "./permissions/lifecycle"; import {CodexElicitationHandler} from "./CodexElicitationHandler"; @@ -96,6 +97,8 @@ import { type LegacySetSessionModelRequest, type LegacySetSessionModelResponse, SESSION_STEERING_METHOD, + SESSION_REWIND_METHOD, + type SessionRewindRequest, type SessionSteeringResponse, type SessionSteerRequest, } from "./AcpExtensions"; @@ -159,6 +162,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, @@ -375,6 +379,11 @@ export class CodexAcpServer { private readonly recovery: AppServerRecovery | null; private initializeRequest: acp.InitializeRequest | null = null; private providerUpdate: Promise | null = null; + 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, @@ -531,6 +540,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 +550,77 @@ export class CodexAcpServer { }; } + private assertNotRewinding(sessionId: string): void { + if (this.rewinds.has(sessionId)) throw RequestError.invalidRequest(`Session ${sessionId} is rewinding`); + } + + private async sessionOperation(sessionId: string, operation: () => Promise): Promise { + this.assertNotRewinding(sessionId); + this.sessionOperations.set(sessionId, (this.sessionOperations.get(sessionId) ?? 0) + 1); + try { return await operation(); } + finally { + const remaining = (this.sessionOperations.get(sessionId) ?? 1) - 1; + if (remaining === 0) this.sessionOperations.delete(sessionId); + else this.sessionOperations.set(sessionId, remaining); + } + } + + private async rewindSession(params: SessionRewindRequest): Promise<{rewound: boolean}> { + // A later provider restart waits for this operation; an earlier restart completes first. + while (this.providerUpdate !== null) await this.waitForProviderUpdate(); + this.assertNotRewinding(params.sessionId); + const state = this.sessions.get(params.sessionId); + if (!state) throw RequestError.invalidParams(undefined, `Unknown session: ${params.sessionId}`); + if (this.rewindRecoveryRequired.has(params.sessionId)) { + throw RequestError.invalidRequest("Previous rewind outcome is uncertain; load the session before continuing"); + } + if (this.sessionIsClosing(params.sessionId) || this.sessionOperations.has(params.sessionId)) { + throw RequestError.invalidRequest("Session lifecycle or settings update is in progress"); + } + const prompt = this.activePrompts.get(params.sessionId); + // A turn/start that has not answered could write after the rewind. Do not synthesize + // completion or reuse closeSession's early-return path for this history mutation. + if (prompt && !prompt.currentTurn) throw RequestError.invalidRequest("Prompt startup is pending; cancel or retry after the turn starts"); + let release!: () => void; + this.rewinds.set(params.sessionId, new Promise(resolve => { release = resolve; })); + let mutationStarted = false; + try { + const sessionReady = this.ensureSessionReady(state); + if (sessionReady) await sessionReady; + 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"); + } + } + 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 listHooks(cwd: string): Promise { if (this.providerUpdate !== null) await this.waitForProviderUpdate(); const appServer = this.ensureAppServer(); @@ -555,6 +636,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 +672,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); @@ -1130,10 +1220,37 @@ export class CodexAcpServer { } private installSessionState(sessionState: SessionState): void { - this.sessions.get(sessionState.sessionId)?.asyncTasks.clear(); + const previous = this.sessions.get(sessionState.sessionId); + previous?.asyncTasks.clear(); + this.retainRetiredTitleGenerator(sessionState.sessionId, previous?.titleGen); this.sessions.set(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 { if (isCodexAuthRequest(request) && request.methodId === "gateway") { return "custom-gateway"; @@ -1142,6 +1259,14 @@ export class CodexAcpServer { } async loadSession(params: acp.LoadSessionRequest): Promise { + return this.sessionOperation(params.sessionId, async () => { + const result = await this.loadSessionDuringOperation(params); + this.rewindRecoveryRequired.delete(params.sessionId); + return result; + }); + } + + private async loadSessionDuringOperation(params: acp.LoadSessionRequest): Promise { return this.recovery === null ? await this.loadSessionOnce(params) : await this.recovery.trackOpen(params.sessionId, () => this.loadSessionOnce(params)); @@ -1198,6 +1323,13 @@ export class CodexAcpServer { } async resumeSession(params: acp.ResumeSessionRequest): Promise { + return this.sessionOperation(params.sessionId, async () => { + const result = await this.resumeSessionDuringOperation(params); + return result; + }); + } + + private async resumeSessionDuringOperation(params: acp.ResumeSessionRequest): Promise { return this.recovery === null ? await this.resumeSessionOnce(params) : await this.recovery.trackOpen(params.sessionId, () => this.resumeSessionOnce(params)); @@ -1225,6 +1357,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,6 +1410,13 @@ export class CodexAcpServer { } async closeSession(params: acp.CloseSessionRequest): Promise { + 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); @@ -1299,6 +1445,7 @@ export class CodexAcpServer { logger.log("Session closed", {sessionId: params.sessionId}); } finally { if (this.getSessionGeneration(params.sessionId) === closeGeneration) { + this.retainRetiredTitleGenerator(params.sessionId, this.sessions.get(params.sessionId)?.titleGen); this.sessions.delete(params.sessionId); this.mcpSessionStartup.close(params.sessionId); this.codexAcpClient.appServerClient.mcpStartup.forgetThread(params.sessionId); @@ -1313,7 +1460,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}); @@ -1383,6 +1545,7 @@ export class CodexAcpServer { _params: acp.AuthenticateRequest, requestId?: acp.JsonRpcId, ): Promise { + if (this.rewinds.size > 0) throw RequestError.invalidRequest("Session rewind is in progress"); logger.log("Authenticate request received"); const appServer = this.ensureAppServer(); if (appServer) await appServer; @@ -1423,6 +1586,7 @@ export class CodexAcpServer { } async logout(_params: acp.LogoutRequest): Promise { + if (this.rewinds.size > 0) throw RequestError.invalidRequest("Session rewind is in progress"); logger.log("Logout request received"); const appServer = this.ensureAppServer(); if (appServer) await appServer; @@ -1436,12 +1600,16 @@ export class CodexAcpServer { } async setProvider(params: acp.SetProviderRequest): Promise { + while (this.rewinds.size > 0) await Promise.all([...this.rewinds.values()]); + if (this.sessionOperations.size > 0) throw RequestError.invalidRequest("A session lifecycle or settings operation is in progress"); this.codexAcpClient.setProvider(params); await this.enqueueProviderUpdate((client) => client.setProvider(params)); return { }; } async disableProvider(params: acp.DisableProviderRequest): Promise { + while (this.rewinds.size > 0) await Promise.all([...this.rewinds.values()]); + if (this.sessionOperations.size > 0) throw RequestError.invalidRequest("A session lifecycle or settings operation is in progress"); this.codexAcpClient.disableProvider(params); if (params.providerId !== OPENAI_PROVIDER_ID) { return { }; @@ -1844,6 +2012,7 @@ export class CodexAcpServer { async setSessionMode( _params: acp.SetSessionModeRequest, ): Promise { + this.assertNotRewinding(_params.sessionId); logger.log("Set session mode requested", { sessionId: _params.sessionId, modeId: _params.modeId @@ -1856,6 +2025,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 +2141,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 +2210,14 @@ export class CodexAcpServer { * {@link performSteeringRequest}. */ async executeOrQueueSteeringRequest(params: SessionSteerRequest): Promise { + return this.sessionOperation(params.sessionId, async () => { + const result = await this.executeOrQueueSteeringRequestDuringOperation(params); + return result; + }); + } + + private async executeOrQueueSteeringRequestDuringOperation(params: SessionSteerRequest): Promise { + if (this.rewindRecoveryRequired.has(params.sessionId)) throw RequestError.invalidRequest("Previous rewind outcome is uncertain; load the session before continuing"); const queue = this.getSteeringQueue(params.sessionId); try { return await queue.enqueue(params); @@ -2866,7 +3057,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] : []) @@ -2923,47 +3114,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) { @@ -3256,6 +3406,12 @@ 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"); + } + if (this.sessionIsClosing(params.sessionId)) throw RequestError.invalidRequest("Session is closing"); + if (this.activePrompts.has(params.sessionId)) throw RequestError.invalidRequest("Prompt is already running"); logger.log("Prompt received", { sessionId: params.sessionId, prompt: params.prompt, diff --git a/src/CodexAppServerClient.ts b/src/CodexAppServerClient.ts index 9a1f95c8d..306553e26 100644 --- a/src/CodexAppServerClient.ts +++ b/src/CodexAppServerClient.ts @@ -73,6 +73,8 @@ import type { Turn, ThreadResumeParams, ThreadResumeResponse, + ThreadRevertParams, + ThreadRevertResponse, ThreadSettings, ThreadStartParams, ThreadStartResponse, @@ -616,6 +618,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); } @@ -1233,7 +1243,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..504d18012 100644 --- a/src/CodexJsonRpcConnection.ts +++ b/src/CodexJsonRpcConnection.ts @@ -43,11 +43,11 @@ export function startCodexConnection( if (codexPath) { // cross-spawn runs `.cmd` shims through cmd.exe with correct escaping for the TOML hook override. codex = process.platform === "win32" - ? crossSpawn(codexPath, appServerStartupArgs, {env: spawnEnv}) as ChildProcessWithoutNullStreams - : spawn(codexPath, appServerStartupArgs, {env: spawnEnv}); + ? crossSpawn(codexPath, appServerStartupArgs, {env: spawnEnv, windowsHide: true}) as ChildProcessWithoutNullStreams + : spawn(codexPath, appServerStartupArgs, {env: spawnEnv, windowsHide: true}); } else { const bundledCodexPath = require.resolve("@openai/codex/bin/codex.js"); - codex = spawn(process.execPath, [bundledCodexPath, ...appServerStartupArgs], {env: spawnEnv}); + codex = spawn(process.execPath, [bundledCodexPath, ...appServerStartupArgs], {env: spawnEnv, windowsHide: true}); } attachLogs(codex); 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/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/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..16773f09b 100644 --- a/src/__tests__/CodexACPAgent/initialize.test.ts +++ b/src/__tests__/CodexACPAgent/initialize.test.ts @@ -91,7 +91,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-rewind-lifecycle.test.ts b/src/__tests__/CodexACPAgent/session-rewind-lifecycle.test.ts new file mode 100644 index 000000000..99adaf5de --- /dev/null +++ b/src/__tests__/CodexACPAgent/session-rewind-lifecycle.test.ts @@ -0,0 +1,104 @@ +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"}]}; +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"}), + ]) 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("steering lifecycle",()=>{ + it("blocks rewind while a steer is already in flight",async()=>{ + const {agent,native,client,state}=setup();state.currentTurnId="active"; + const gate=deferred();const call=vi.spyOn(native,"turnSteer").mockReturnValue(gate.promise); + 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"}); + }); +}); 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__/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__/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/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__/scenarios/data/air/agent-message-and-reasoning.jsonl b/src/__tests__/scenarios/data/air/agent-message-and-reasoning.jsonl index e145bc278..aec503baf 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":{"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}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/background-terminal.jsonl b/src/__tests__/scenarios/data/air/background-terminal.jsonl index dfb2dd265..c3164d50f 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":{"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}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/collab-agent.jsonl b/src/__tests__/scenarios/data/air/collab-agent.jsonl index 5776128ea..1d33aa24a 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":{"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}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/collab-controls.jsonl b/src/__tests__/scenarios/data/air/collab-controls.jsonl index e231ae372..04b8d7b82 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":{"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}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/command-approval.jsonl b/src/__tests__/scenarios/data/air/command-approval.jsonl index 3a9a295eb..f0cf90572 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":{"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}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/command-failed.jsonl b/src/__tests__/scenarios/data/air/command-failed.jsonl index f362f218c..e03111936 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":{"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}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/command-output-stdin.jsonl b/src/__tests__/scenarios/data/air/command-output-stdin.jsonl index b799a78ad..dcd8d09b3 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":{"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}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/command-without-streamed-output.jsonl b/src/__tests__/scenarios/data/air/command-without-streamed-output.jsonl index 62ed2c03a..d8cbaa0fd 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":{"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}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/context-compaction.jsonl b/src/__tests__/scenarios/data/air/context-compaction.jsonl index e7ce2cbbd..aa8741906 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":{"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}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/dynamic-tool.jsonl b/src/__tests__/scenarios/data/air/dynamic-tool.jsonl index 639264187..06d2bf72d 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":{"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}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/file-change-approval.jsonl b/src/__tests__/scenarios/data/air/file-change-approval.jsonl index 06fe6a97c..9ae49ebdb 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":{"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}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/file-changes.jsonl b/src/__tests__/scenarios/data/air/file-changes.jsonl index 2eb2ee2af..778c5e628 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":{"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}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/fuzzy-file-search.jsonl b/src/__tests__/scenarios/data/air/fuzzy-file-search.jsonl index aba2ef5a9..98c964066 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":{"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}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/goal-update.jsonl b/src/__tests__/scenarios/data/air/goal-update.jsonl index d9a271e9a..64882f877 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":{"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}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/guardian-review.jsonl b/src/__tests__/scenarios/data/air/guardian-review.jsonl index 37e35e388..286b073cb 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":{"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}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/history-replay.jsonl b/src/__tests__/scenarios/data/air/history-replay.jsonl index 09f8186ca..7b5fc49f2 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":{"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}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/load","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/image-generation.jsonl b/src/__tests__/scenarios/data/air/image-generation.jsonl index 4197ce556..2098e44c3 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":{"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}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/image-view.jsonl b/src/__tests__/scenarios/data/air/image-view.jsonl index 6a40d5c5e..3e3b2677a 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":{"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}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/late-subagent-update.jsonl b/src/__tests__/scenarios/data/air/late-subagent-update.jsonl index e79ae803c..64b6bc222 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":{"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}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/mcp-elicitation.jsonl b/src/__tests__/scenarios/data/air/mcp-elicitation.jsonl index 65ee9aeaa..608802f55 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":{"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}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/mcp-startup-failure.jsonl b/src/__tests__/scenarios/data/air/mcp-startup-failure.jsonl index a95fab572..5065da3af 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":{"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}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"content":[{"content":{"text":"[codex-acp forwarded startup error] MCP server `db` failed to start: boom","type":"text"},"type":"content"}],"kind":"other","sessionUpdate":"tool_call","status":"failed","title":"mcp__db__startup","toolCallId":"mcp_startup.db."}}} diff --git a/src/__tests__/scenarios/data/air/mcp-tool-approval.jsonl b/src/__tests__/scenarios/data/air/mcp-tool-approval.jsonl index 2651a559a..2a19bd31c 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":{"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}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/mcp-tool.jsonl b/src/__tests__/scenarios/data/air/mcp-tool.jsonl index 40e8fa3bf..4abc99c90 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":{"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}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/mcp-url-elicitation.jsonl b/src/__tests__/scenarios/data/air/mcp-url-elicitation.jsonl index b13da4a5c..3efff59db 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":{"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}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/native-subagent-session.jsonl b/src/__tests__/scenarios/data/air/native-subagent-session.jsonl index d615abd7a..b178a80f3 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":{"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}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/nested-subagent-session.jsonl b/src/__tests__/scenarios/data/air/nested-subagent-session.jsonl index 68ed64971..0adcb23e2 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":{"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}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/network-and-sandbox-approval.jsonl b/src/__tests__/scenarios/data/air/network-and-sandbox-approval.jsonl index 56f5aa178..3bdc0304e 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":{"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}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/plan-deltas-and-turn-plan.jsonl b/src/__tests__/scenarios/data/air/plan-deltas-and-turn-plan.jsonl index 05e8acf72..3973d0dd6 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":{"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}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/plan-review-permission.jsonl b/src/__tests__/scenarios/data/air/plan-review-permission.jsonl index 60b368ecc..e9663eb32 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":{"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}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"plan","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/plan-stream.jsonl b/src/__tests__/scenarios/data/air/plan-stream.jsonl index 66a79c3d8..6056c7641 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":{"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}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/read-search-list.jsonl b/src/__tests__/scenarios/data/air/read-search-list.jsonl index 5a926020b..d78280bcf 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":{"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}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/subagent-activity.jsonl b/src/__tests__/scenarios/data/air/subagent-activity.jsonl index 96c9820c9..981317c31 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":{"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}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/data/air/web-search.jsonl b/src/__tests__/scenarios/data/air/web-search.jsonl index 34ee899c6..208b1be57 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":{"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}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/__tests__/scenarios/scenario-harness.ts b/src/__tests__/scenarios/scenario-harness.ts index fdbfe0bf2..4691150ac 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. */ diff --git a/src/index.ts b/src/index.ts index 8fb26044b..c181d4344 100644 --- a/src/index.ts +++ b/src/index.ts @@ -31,6 +31,8 @@ import { sessionRenameParamsParser, } from "./SessionIndex"; +import {SESSION_REWIND_METHOD} from "./SessionRewind"; + const emptyExtensionParamsParser = z.preprocess( (params) => params ?? {}, z.object({}).passthrough() @@ -63,6 +65,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), @@ -187,12 +200,13 @@ function startAcpServer() { .onRequest(LEGACY_SET_SESSION_MODEL_METHOD, legacySetSessionModelParamsParser, (ctx) => getAgent().extMethod(LEGACY_SET_SESSION_MODEL_METHOD, ctx.params)) .onRequest(SESSION_STEERING_METHOD, sessionSteerParamsParser, (ctx) => getAgent().extMethod(SESSION_STEERING_METHOD, ctx.params)) .onRequest(ASYNC_TASK_STOP_METHOD, asyncTaskStopParamsParser, (ctx) => getAgent().extMethod(ASYNC_TASK_STOP_METHOD, ctx.params)) + .onRequest(SESSION_REWIND_METHOD, sessionRewindParamsParser, (ctx) => getAgent().extMethod(SESSION_REWIND_METHOD, ctx.params)) .onRequest(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) => getAgent().setSessionArchived(ctx.params, true)) + .onRequest(SESSION_UNARCHIVE_METHOD, sessionArchiveParamsParser, (ctx) => getAgent().setSessionArchived(ctx.params, 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);