Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
98 changes: 98 additions & 0 deletions docs/session-rewind-extension.md
Original file line number Diff line number Diff line change
@@ -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 <directory>` selects an artifact parent inside this checkout only.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
8 changes: 8 additions & 0 deletions readme-dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
102 changes: 102 additions & 0 deletions scripts/native-e2e.mjs
Original file line number Diff line number Diff line change
@@ -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 <artifact-parent-directory>]');
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;
}
Loading