diff --git a/README.md b/README.md index 621d7db8..22a2a40e 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ supported integrations: | --- | --- | --- | --- | | Claude | [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | `tokenjuice install claude-code` | `~/.claude/settings.json` | | CodeBuddy | [CodeBuddy](https://codebuddy.tencent.com/) | `tokenjuice install codebuddy` | `~/.codebuddy/settings.json` | -| Codex | [Codex CLI](https://github.com/openai/codex) | `tokenjuice install codex` | `~/.codex/hooks.json` | +| Codex | [Codex CLI](https://github.com/openai/codex) ([integration notes](docs/codex-integration.md)) | `tokenjuice install codex` | `~/.codex/hooks.json` | | Cursor | [Cursor](https://cursor.com/docs/hooks) | `tokenjuice install cursor` | `~/.cursor/hooks.json` | | Droid | [Droid (Factory CLI)](https://docs.factory.ai/cli/configuration/hooks-guide) | `tokenjuice install droid` | `~/.factory/settings.json` | | GitHub Copilot CLI | [GitHub Copilot CLI](https://github.com/github/copilot-cli) | `tokenjuice install copilot-cli` | `~/.copilot/hooks/tokenjuice-cli.json` | @@ -253,6 +253,7 @@ direct payload: - [Coder Agents integration](docs/coder-agents-integration.md) - [CodeRabbit integration](docs/coderabbit-integration.md) - [Command Code integration](docs/command-code-integration.md) +- [Codex CLI integration](docs/codex-integration.md) - [Crush integration](docs/crush-integration.md) - [Cursor integration](docs/cursor-integration.md) - [CodeBuddy integration](docs/codebuddy-integration.md) diff --git a/docs/codex-integration.md b/docs/codex-integration.md new file mode 100644 index 00000000..6eefa76a --- /dev/null +++ b/docs/codex-integration.md @@ -0,0 +1,61 @@ +# Codex CLI integration + +`tokenjuice install codex` adds a `PostToolUse` hook for Bash results to +`~/.codex/hooks.json`. Outputs that produce a worthwhile reduction are replaced +with compacted context; small, low-savings, and protected inspection results are +left unchanged. + +## Expected replacement status + +With the currently tested Codex CLI, a PostToolUse hook must return +`continue:false` to suppress the original tool result. A successful Tokenjuice +rewrite has been observed as: + +```text +PostToolUse hook (stopped) + hook context: + stop: Tokenjuice replaced the original Bash output with the compacted context above. +``` + +Interpret this status as a successful replacement only when both the Tokenjuice +replacement reason and compacted hook context are present. In that case, +`stopped` describes the hook suppressing the original result; it is not the Bash +exit status. The authenticated regression verifies that the agent can produce +a later assistant response after the replacement. + +Without `continue:false`, the currently tested Codex CLI retains the full +original result and adds the summary beside it, which does not provide the +intended context-token savings. Until Codex exposes a clean "replace output" +primitive, the `stopped` label is an expected UI tradeoff for real output +replacement. + +Use `tokenjuice wrap --raw -- ` when the full command output is +required. This escape hatch reruns the command; review side effects before using +it with commands that mutate files or external systems. + +## Local verification + +To point the real Codex home at the current checkout: + +```bash +pnpm build +node dist/cli/main.js install codex --local +node dist/cli/main.js doctor codex --local +``` + +`doctor` should report `status: ok`. Use it first when the hook is disabled, +stale, or missing. + +Run the authenticated live regression separately: + +```bash +pnpm e2e:codex-live +``` + +The E2E builds and installs into an isolated temporary `CODEX_HOME`; it does not +require the real-home local install above. It consumes Codex quota and requires +an existing login at `$CODEX_HOME/auth.json` (or `~/.codex/auth.json`). Set +`TOKENJUICE_CODEX_LIVE_SOURCE_HOME` to select a different authenticated home. +It verifies that the compacted context and replacement reason are model-visible, +a later assistant response is present, and the original marker is absent from +the compacted context and function-call output. diff --git a/docs/integration-playbook.md b/docs/integration-playbook.md index 7e5118bd..a3402aee 100644 --- a/docs/integration-playbook.md +++ b/docs/integration-playbook.md @@ -130,6 +130,20 @@ for truncation-related debugging, verify both boundaries explicitly: - capture truncation: if output includes `[tokenjuice: output truncated]`, rerun with a larger capture ceiling (for example `--max-capture-bytes 52428800`). - do not treat these as the same failure mode; reducer bypass and capture-size tuning solve different problems. +### Codex live context replacement + +Run the authenticated Codex regression manually: + +```bash +pnpm e2e:codex-live +``` + +This command builds the current source, installs its hook into an isolated temporary `CODEX_HOME`, runs a fixed fake `gh` command through the real `codex exec`, and inspects the saved transcript plus Tokenjuice hook debug output. It requires an existing Codex login at `$CODEX_HOME/auth.json` (or `~/.codex/auth.json`); set `TOKENJUICE_CODEX_LIVE_SOURCE_HOME` to select a different authenticated home. + +The transcript must contain the compacted developer context, the model-visible replacement reason, and a later assistant response, while excluding the raw marker from the compacted context and function-call output. With the currently tested Codex CLI, replacement renders as `PostToolUse hook (stopped)` because `continue:false` is required to suppress the original result. Treat that status as expected only when the Tokenjuice replacement reason and compacted context are present; it describes hook replacement, not the Bash exit status. + +The command invokes a model and consumes account quota, so keep it out of normal CI. It removes the temporary Codex home and authentication symlink before reporting success. + ## docs updates required in same PR when adding a host integration, update: diff --git a/docs/spec.md b/docs/spec.md index 57c34a51..2a5837dd 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -380,7 +380,7 @@ supported host hooks: | CodeRabbit | `tokenjuice install coderabbit` | `.coderabbit.yaml` | ✴️ Beta. Inserts marker-delimited `reviews.path_instructions` guidance that tells CodeRabbit review, finishing-touch, chat, and tool workflows to use `tokenjuice wrap` for noisy terminal commands and `tokenjuice wrap --raw -- ` only when raw bytes are needed; guidance-only, because CodeRabbit config does not intercept review comments or command output; see `docs/coderabbit-integration.md` | | CodeBuddy (Linux/macOS/WSL) | `tokenjuice install codebuddy` | `~/.codebuddy/settings.json` | Uses `PreToolUse` shell input rewriting (same pattern as Cursor) to route Bash commands through `tokenjuice wrap`; preserves unrelated hooks that share a matcher group with the tokenjuice entry; `tokenjuice install codebuddy --local` is available for repo-local verification; native Windows shell interception is intentionally blocked for now; see `docs/codebuddy-integration.md` | | Command Code | `tokenjuice install command-code` | `~/.commandcode/settings.json` / `.commandcode/settings.json` | ✴️ Beta. Uses a `PostToolUse` hook with matcher `shell`; compacted context is injected through `hookSpecificOutput.additionalContext` alongside the original shell output; `tokenjuice install command-code --local` is available for repo-local verification; see `docs/command-code-integration.md` | -| Codex CLI | `tokenjuice install codex` | `~/.codex/hooks.json` | `tokenjuice install codex --local` is available for repo-local verification | +| Codex CLI | `tokenjuice install codex` | `~/.codex/hooks.json` | Rewrites suppress the original Bash result and inject compacted context. With the currently tested Codex CLI, this replacement renders as `PostToolUse hook (stopped)` because output suppression requires `continue:false`; when the Tokenjuice replacement reason and compacted context are present, this is a hook-replacement status rather than a Bash failure. `tokenjuice install codex --local` is available for repo-local verification; see `docs/codex-integration.md` | | Continue | `tokenjuice install continue` | `.continue/rules/tokenjuice.md` | ✴️ Beta. Installs a workspace rule that tells Continue agents to use `tokenjuice wrap` for noisy terminal commands and `tokenjuice wrap --raw -- ` only when raw bytes are needed; guidance-only, because Continue rules do not intercept tool output; see `docs/continue-integration.md` | | Crush | `tokenjuice install crush` | `.crush/skills/tokenjuice/SKILL.md` | ✴️ Beta. Installs a project Agent Skill that tells Crush to use `tokenjuice wrap` for noisy terminal commands and `tokenjuice wrap --raw -- ` only when raw bytes are needed; guidance-only, because Crush hook composition and stateful shell behavior make command rewriting unsafe; see `docs/crush-integration.md` | | Cursor (Linux/macOS/WSL) | `tokenjuice install cursor` | `~/.cursor/hooks.json` | Uses `preToolUse` shell input rewriting to route commands through `tokenjuice wrap`; `tokenjuice install cursor --local` is available for repo-local verification; native Windows shell interception is intentionally blocked for now; see `docs/cursor-integration.md` | diff --git a/package.json b/package.json index 022b3379..c21e20be 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "bench:fixtures": "node scripts/bench.mjs fixtures", "bench:verify": "node scripts/bench.mjs verify", "contracts": "publint run --strict --pack pnpm", + "e2e:codex-live": "pnpm build && node scripts/codex-live-e2e.mjs", "e2e:local": "pnpm build && node scripts/local-host-e2e.mjs", "generate:builtin-rules": "node scripts/generate-builtin-rules.mjs", "prepare": "pnpm repair:pnpm-bin-shims", diff --git a/scripts/codex-live-e2e.mjs b/scripts/codex-live-e2e.mjs new file mode 100644 index 00000000..8f7bb0c4 --- /dev/null +++ b/scripts/codex-live-e2e.mjs @@ -0,0 +1,315 @@ +#!/usr/bin/env node + +import { access, mkdtemp, mkdir, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises"; +import { homedir, tmpdir } from "node:os"; +import { delimiter, dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawn } from "node:child_process"; + +const repoRoot = dirname(fileURLToPath(new URL("../package.json", import.meta.url))); +const distCliPath = join(repoRoot, "dist", "cli", "main.js"); +const tempRoot = await mkdtemp(join(tmpdir(), "tokenjuice-codex-live-e2e-")); +const rawMarker = "TOKENJUICE_CODEX_LIVE_RAW_MARKER"; +const replacementReason = "Tokenjuice replaced the original Bash output with the compacted context above."; +const ghArgs = [ + "pr", + "view", + "206", + "--json", + "number,title,url,isDraft,headRefName,headRefOid,mergeStateStatus,statusCheckRollup", +]; +const ghCommand = `gh ${ghArgs.join(" ")}`; +const ghPayload = { + headRefName: "fix/compound-output-contract", + headRefOid: "42238adab077bd3f39f862f92501bfa3d71bfae8", + isDraft: false, + mergeStateStatus: "UNKNOWN", + number: 206, + rawMarker, + statusCheckRollup: [ + { + __typename: "CheckRun", + completedAt: "2026-06-18T08:25:58Z", + conclusion: "SUCCESS", + detailsUrl: "https://github.com/vincentkoc/tokenjuice/actions/runs/27746720696/job/82086723525", + name: "assign", + startedAt: "2026-06-18T08:25:53Z", + status: "COMPLETED", + workflowName: "Auto Assign", + }, + { + __typename: "CheckRun", + completedAt: "2026-06-18T08:26:06Z", + conclusion: "SUCCESS", + detailsUrl: "https://github.com/vincentkoc/tokenjuice/actions/runs/27746725443/job/82086739798", + name: "assign", + startedAt: "2026-06-18T08:26:00Z", + status: "COMPLETED", + workflowName: "Auto Assign", + }, + { + __typename: "CheckRun", + completedAt: "2026-06-18T08:26:51Z", + conclusion: "SUCCESS", + detailsUrl: "https://github.com/vincentkoc/tokenjuice/actions/runs/27746720707/job/82086723468", + name: "quality", + startedAt: "2026-06-18T08:25:53Z", + status: "COMPLETED", + workflowName: "CI", + }, + { + __typename: "CheckRun", + completedAt: "2026-06-18T08:25:59Z", + conclusion: "SUCCESS", + detailsUrl: "https://github.com/vincentkoc/tokenjuice/actions/runs/27746720653/job/82086723479", + name: "Update Release Draft", + startedAt: "2026-06-18T08:25:53Z", + status: "COMPLETED", + workflowName: "Release Drafter", + }, + { + __typename: "CheckRun", + completedAt: "2026-06-18T08:27:14Z", + conclusion: "SUCCESS", + detailsUrl: "https://github.com/vincentkoc/tokenjuice/actions/runs/27746720707/job/82086898704", + name: "package", + startedAt: "2026-06-18T08:26:55Z", + status: "COMPLETED", + workflowName: "CI", + }, + { + __typename: "StatusContext", + context: "AccessLint", + startedAt: "2026-06-18T08:27:20Z", + state: "PENDING", + targetUrl: "", + }, + ], + title: "fix(core): preserve compound command output", + url: "https://github.com/vincentkoc/tokenjuice/pull/206", +}; + +function fail(message) { + throw new Error(message); +} + +function assert(condition, message) { + if (!condition) { + fail(message); + } +} + +function run(command, args, options = {}) { + const { + cwd = repoRoot, + env = {}, + timeoutMs = 180_000, + } = options; + + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd, + env: { ...process.env, ...env }, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + const timer = setTimeout(() => { + child.kill("SIGTERM"); + reject(new Error(`timed out after ${timeoutMs}ms: ${[command, ...args].join(" ")}`)); + }, timeoutMs); + + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + child.on("error", (error) => { + clearTimeout(timer); + reject(error); + }); + child.on("close", (code, signal) => { + clearTimeout(timer); + const exitCode = code ?? 128; + if (exitCode !== 0) { + reject(new Error([ + `command failed: ${[command, ...args].join(" ")}`, + `exit: ${exitCode}${signal ? ` signal: ${signal}` : ""}`, + stdout ? `stdout:\n${stdout}` : "", + stderr ? `stderr:\n${stderr}` : "", + ].filter(Boolean).join("\n"))); + return; + } + resolve({ code: exitCode, stdout, stderr }); + }); + }); +} + +async function assertFile(path, hint) { + try { + await access(path); + } catch { + fail(`${path} is missing${hint ? `; ${hint}` : ""}`); + } +} + +async function findFiles(root, suffix) { + const entries = await readdir(root, { withFileTypes: true }); + const nested = await Promise.all(entries.map(async (entry) => { + const path = join(root, entry.name); + if (entry.isDirectory()) { + return findFiles(path, suffix); + } + return entry.isFile() && entry.name.endsWith(suffix) ? [path] : []; + })); + return nested.flat(); +} + +function messageText(payload) { + if (payload.type !== "message" || !Array.isArray(payload.content)) { + return ""; + } + return payload.content + .map((item) => typeof item?.text === "string" ? item.text : "") + .join(""); +} + +function buildFakeGhSource() { + return [ + "#!/usr/bin/env node", + "", + `const expectedArgs = ${JSON.stringify(ghArgs)};`, + `const payload = ${JSON.stringify(ghPayload)};`, + "const actualArgs = process.argv.slice(2);", + "if (JSON.stringify(actualArgs) !== JSON.stringify(expectedArgs)) {", + " process.stderr.write(`unexpected fake gh arguments: ${actualArgs.join(\" \")}\\n`);", + " process.exit(64);", + "}", + "process.stdout.write(`${JSON.stringify(payload)}\\n`);", + "", + ].join("\n"); +} + +async function runLiveE2E() { + const sourceCodexHome = process.env.TOKENJUICE_CODEX_LIVE_SOURCE_HOME + ?? process.env.CODEX_HOME + ?? join(homedir(), ".codex"); + const sourceAuthPath = join(sourceCodexHome, "auth.json"); + const codexHome = join(tempRoot, "codex-home"); + const fakeBin = join(tempRoot, "bin"); + const fakeGhPath = join(fakeBin, "gh"); + const transcriptRoot = join(codexHome, "sessions"); + const debugPath = join(codexHome, "tokenjuice-hook.last.json"); + + await assertFile(distCliPath, "run `pnpm build` first"); + await assertFile(sourceAuthPath, "run `codex login` before this manual live E2E"); + await mkdir(codexHome, { recursive: true }); + await mkdir(fakeBin, { recursive: true }); + await symlink(sourceAuthPath, join(codexHome, "auth.json")); + await writeFile(fakeGhPath, buildFakeGhSource(), { encoding: "utf8", mode: 0o755 }); + await writeFile(join(fakeBin, "gh.cmd"), "@echo off\r\nnode \"%~dp0gh\" %*\r\n", "utf8"); + + const env = { + CODEX_HOME: codexHome, + PATH: `${fakeBin}${delimiter}${process.env.PATH ?? ""}`, + TOKENJUICE_DEBUG: "1", + // This regression must exercise compaction even when the caller normally disables omission. + TOKENJUICE_NO_OMISSION: "", + }; + const codexVersion = (await run("codex", ["--version"], { env })).stdout.trim(); + await run(process.execPath, [distCliPath, "install", "codex", "--local"], { env }); + + const prompt = [ + `Run exactly this one shell command and do not use any other tool: ${ghCommand}.`, + "After it returns, respond exactly TOKENJUICE_CODEX_LIVE_E2E_DONE.", + ].join(" "); + await run("codex", [ + "exec", + "--sandbox", + "read-only", + "--dangerously-bypass-hook-trust", + "--skip-git-repo-check", + "--color", + "never", + prompt, + ], { + cwd: tempRoot, + env, + }); + + const transcriptPaths = await findFiles(transcriptRoot, ".jsonl"); + assert(transcriptPaths.length === 1, `expected one Codex transcript, found ${transcriptPaths.length}`); + const transcriptPath = transcriptPaths[0]; + assert(typeof transcriptPath === "string", "expected Codex transcript path"); + const transcriptEntries = (await readFile(transcriptPath, "utf8")) + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line)); + const responseItems = transcriptEntries + .filter((entry) => entry.type === "response_item") + .map((entry) => entry.payload); + const summaryIndex = responseItems.findIndex((payload) => + payload.type === "message" + && payload.role === "developer" + && messageText(payload).includes("#206 fix(core): preserve compound command output") + ); + const summaryText = summaryIndex >= 0 ? messageText(responseItems[summaryIndex]) : undefined; + assert(typeof summaryText === "string", "expected Tokenjuice summary in Codex developer context"); + assert(summaryText.includes("need raw? `tokenjuice wrap --raw -- `"), "expected raw rerun hint in Tokenjuice summary"); + assert(!summaryText.includes(rawMarker), "raw marker leaked into Tokenjuice summary"); + const continuedAssistantText = responseItems + .slice(summaryIndex + 1) + .filter((payload) => payload.type === "message" && payload.role === "assistant") + .map(messageText) + .find((text) => text.trim().length > 0); + assert(typeof continuedAssistantText === "string", "expected the assistant to continue after replacement"); + + const functionCallOutputs = responseItems + .filter((payload) => payload.type === "function_call_output") + .map((payload) => payload.output) + .filter((output) => typeof output === "string"); + assert(functionCallOutputs.length === 1, `expected one model-visible function_call_output, found ${functionCallOutputs.length}`); + const functionCallOutput = functionCallOutputs[0]; + assert(typeof functionCallOutput === "string", "expected model-visible function_call_output"); + assert(!functionCallOutput.includes(rawMarker), "raw marker leaked into model-visible function_call_output"); + assert(functionCallOutput === replacementReason, `unexpected model-visible function_call_output: ${functionCallOutput}`); + + const debug = JSON.parse(await readFile(debugPath, "utf8")); + assert(debug.rewrote === true, "expected Tokenjuice hook debug rewrote:true"); + assert(typeof debug.rawChars === "number", "expected rawChars in Tokenjuice hook debug"); + assert(typeof debug.reducedChars === "number", "expected reducedChars in Tokenjuice hook debug"); + + const sessionMeta = transcriptEntries.find((entry) => entry.type === "session_meta"); + const finalModelContextChars = summaryText.length + functionCallOutput.length; + return { + ok: true, + codexVersion, + sessionId: sessionMeta?.payload?.session_id, + checks: { + summaryPresent: true, + rawMarkerAbsentFromFunctionCallOutput: true, + replacementReasonPresent: true, + turnContinued: true, + hookRewrote: true, + }, + chars: { + raw: debug.rawChars, + compressed: debug.reducedChars, + summaryContext: summaryText.length, + functionCallOutput: functionCallOutput.length, + finalModelContext: finalModelContextChars, + }, + }; +} + +let report; +try { + report = await runLiveE2E(); +} finally { + await rm(tempRoot, { recursive: true, force: true }); +} + +process.stdout.write(`${JSON.stringify({ ...report, cleanedUp: true }, null, 2)}\n`); diff --git a/scripts/local-host-e2e.mjs b/scripts/local-host-e2e.mjs index cd64adb4..0e8a1e32 100644 --- a/scripts/local-host-e2e.mjs +++ b/scripts/local-host-e2e.mjs @@ -20,10 +20,6 @@ function assert(condition, message) { } } -function compactableOutput(prefix, count) { - return Array.from({ length: count }, (_, index) => `${prefix}/example-${index + 1}.json`).join("\n"); -} - function postToolUsePayload(command, toolResponse) { return `${JSON.stringify({ hook_event_name: "PostToolUse", @@ -128,8 +124,18 @@ async function runCodexE2E() { assert(report.status === "ok", `expected Codex doctor status ok, got ${doctor.stdout}`); const payload = postToolUsePayload( - "find src/rules -maxdepth 2 -type f | head -n 40", - compactableOutput("src/rules", 40), + "git status", + [ + "On branch pr-65478-security-fix", + "Your branch and 'origin/pr-65478-security-fix' have diverged,", + "and have 8 and 642 different commits each, respectively.", + "", + "Changes not staged for commit:", + "\tmodified: src/agents/pi-embedded-runner/run/attempt.prompt-helpers.ts", + "\tmodified: src/agents/pi-embedded-runner/run/attempt.test.ts", + "", + "no changes added to commit", + ].join("\n"), ); const hook = await run(process.execPath, [distCliPath, "codex-post-tool-use"], { env: { CODEX_HOME: codexHome }, @@ -139,10 +145,15 @@ async function runCodexE2E() { assert(hook.stderr === "", `expected Codex hook stderr to stay empty, got ${hook.stderr}`); const output = JSON.parse(hook.stdout); const additionalContext = output.hookSpecificOutput?.additionalContext; + assert(output.continue === false, "expected Codex hook output to replace the original tool result"); assert(output.hookSpecificOutput?.hookEventName === "PostToolUse", "expected Codex PostToolUse output"); assert(typeof additionalContext === "string", "expected Codex additionalContext"); - assert(additionalContext.includes("40 matches"), "expected Codex hook output to contain compacted match count"); - assert(additionalContext.includes("src/rules/example-1.json"), "expected Codex hook output to include compacted paths"); + assert(additionalContext.includes("Changes not staged:"), "expected Codex hook output to retain status context"); + assert( + additionalContext.includes("M: src/agents/pi-embedded-runner/run/attempt.prompt-helpers.ts"), + "expected Codex hook output to include compacted status paths", + ); + assert(!additionalContext.includes("and have 8 and 642"), "expected Codex hook output to omit noisy branch details"); assert(additionalContext.includes("tokenjuice wrap --raw -- "), "expected Codex hook output to include raw rerun hint"); assert(!hook.stdout.includes("\"decision\""), "Codex hook feedback must not emit JSON decision:block output"); diff --git a/src/hosts/codex/index.ts b/src/hosts/codex/index.ts index 77c4abc6..67667191 100644 --- a/src/hosts/codex/index.ts +++ b/src/hosts/codex/index.ts @@ -735,9 +735,16 @@ function buildCodexFeedback(inlineText: string, rawRefId?: string): string { return `${inlineText}\n\n${buildCompactionHint(rawRefId)}`; } +const CODEX_COMPACTION_STOP_REASON = "Tokenjuice replaced the original Bash output with the compacted context above."; + function buildCodexReplacementOutput(inlineText: string, rawRefId?: string): Record { const feedback = buildCodexFeedback(inlineText, rawRefId); return { + // Codex uses separate fields for the hook event and model feedback. Set both so replacement + // does not look like a failed tool call, while keeping the full summary in additionalContext. + continue: false, + stopReason: CODEX_COMPACTION_STOP_REASON, + reason: CODEX_COMPACTION_STOP_REASON, hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: feedback, diff --git a/test/hosts/codex.test.ts b/test/hosts/codex.test.ts index d6a3643a..8f20fba9 100644 --- a/test/hosts/codex.test.ts +++ b/test/hosts/codex.test.ts @@ -65,12 +65,18 @@ async function captureStdio(run: () => Promise): Promise<{ code: number; } function parseCodexReplacementOutput(stdout: string): { + continue?: boolean; + stopReason?: string; + reason?: string; hookSpecificOutput?: { hookEventName?: string; additionalContext?: string; }; } { return JSON.parse(stdout) as { + continue?: boolean; + stopReason?: string; + reason?: string; hookSpecificOutput?: { hookEventName?: string; additionalContext?: string; @@ -618,6 +624,9 @@ describe("runCodexPostToolUseHook", () => { expect(code).toBe(0); expect(stderr).toBe(""); + expect(response.continue).toBe(false); + expect(response.stopReason).toBe("Tokenjuice replaced the original Bash output with the compacted context above."); + expect(response.reason).toBe("Tokenjuice replaced the original Bash output with the compacted context above."); expect(response.hookSpecificOutput?.hookEventName).toBe("PostToolUse"); expect(response.hookSpecificOutput?.additionalContext).toContain("Changes not staged:"); expect(response.hookSpecificOutput?.additionalContext).toContain("M: src/agents/pi-embedded-runner/run/attempt.prompt-helpers.ts");