| 1 | import assert from "node:assert/strict"; |
| 2 | import { spawnSync } from "node:child_process"; |
| 3 | import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; |
| 4 | import { tmpdir } from "node:os"; |
| 5 | import { join } from "node:path"; |
| 6 | import { after, beforeEach, test } from "node:test"; |
| 7 | import roundabout from "../plugin/omp/index.js"; |
| 8 | |
| 9 | const temporary = mkdtempSync(join(tmpdir(), "roundabout-omp-")); |
| 10 | const captured = join(temporary, "input.json"); |
| 11 | const mockCli = process.env.ROUNDABOUT_TEST_CLI; |
| 12 | const handlers = new Map(); |
| 13 | const notifications = []; |
| 14 | const context = { |
| 15 | cwd: temporary, |
| 16 | hasUI: true, |
| 17 | agent: { kind: "main" }, |
| 18 | sessionManager: { getSessionId: () => "omp-session" }, |
| 19 | ui: { notify: (...args) => notifications.push(args) }, |
| 20 | }; |
| 21 | const bash = { toolName: "bash", input: { command: "cargo build", env: { KEEP: "yes" }, async: true } }; |
| 22 | |
| 23 | roundabout({ on: (name, handler) => handlers.set(name, handler) }); |
| 24 | |
| 25 | beforeEach(() => { |
| 26 | process.env.ROUNDABOUT_CLI = mockCli; |
| 27 | process.env.ROUNDABOUT_TEST_INPUT = captured; |
| 28 | delete process.env.ROUNDABOUT_TEST_OUTPUT; |
| 29 | delete process.env.ROUNDABOUT_TEST_EXIT; |
| 30 | notifications.length = 0; |
| 31 | rmSync(captured, { force: true }); |
| 32 | }); |
| 33 | |
| 34 | after(() => rmSync(temporary, { recursive: true, force: true })); |
| 35 | |
| 36 | function output(value) { |
| 37 | process.env.ROUNDABOUT_TEST_OUTPUT = JSON.stringify(value); |
| 38 | } |
| 39 | |
| 40 | function input() { |
| 41 | return JSON.parse(readFileSync(captured, "utf8")); |
| 42 | } |
| 43 | |
| 44 | test("maps build guards and preserves wrapped command arguments", async () => { |
| 45 | output({ hookSpecificOutput: { permissionDecision: "deny", permissionDecisionReason: "use roundabout run" } }); |
| 46 | assert.deepEqual(await handlers.get("tool_call")(bash, context), { block: true, reason: "use roundabout run" }); |
| 47 | assert.equal(input().event, "omp-pre-tool-use"); |
| 48 | assert.equal(input().input.tool_name, "Bash"); |
| 49 | output({ hookSpecificOutput: { updatedInput: { command: "roundabout run -s m -- cargo build" } } }); |
| 50 | const result = await handlers.get("tool_call")({ ...bash, input: { ...bash.input, cwd: "/project" } }, context); |
| 51 | assert.equal(input().input.cwd, "/project"); |
| 52 | assert.equal(result.input.command, "roundabout run -s m -- cargo build"); |
| 53 | assert.equal(result.input.async, true); |
| 54 | assert.deepEqual(result.input.env, { KEEP: "yes", ROUNDABOUT_SESSION_ID: "omp-session" }); |
| 55 | }); |
| 56 | |
| 57 | test("fails open for malformed output, nonzero exits and missing CLI", async () => { |
| 58 | process.env.ROUNDABOUT_TEST_OUTPUT = "not JSON"; |
| 59 | assert.equal((await handlers.get("tool_call")(bash, context)).input.command, "cargo build"); |
| 60 | output({ hookSpecificOutput: { permissionDecision: "deny" } }); |
| 61 | process.env.ROUNDABOUT_TEST_EXIT = "1"; |
| 62 | assert.equal((await handlers.get("tool_call")(bash, context)).block, undefined); |
| 63 | process.env.ROUNDABOUT_CLI = join(temporary, "missing-roundabout"); |
| 64 | assert.equal((await handlers.get("tool_call")(bash, context)).block, undefined); |
| 65 | }); |
| 66 | |
| 67 | test("maps artifact policies and bounds advisory continuations", async () => { |
| 68 | output({ decision: "block", reason: "clean artifacts" }); |
| 69 | assert.deepEqual(await handlers.get("session_stop")({ stop_hook_active: true }, context), { |
| 70 | decision: "block", reason: "clean artifacts", |
| 71 | }); |
| 72 | output({ hookSpecificOutput: { additionalContext: "clean artifacts" } }); |
| 73 | assert.deepEqual(await handlers.get("session_stop")({ stop_hook_active: false }, context), { |
| 74 | continue: true, additionalContext: "clean artifacts", |
| 75 | }); |
| 76 | assert.equal(await handlers.get("session_stop")({ stop_hook_active: true }, context), undefined); |
| 77 | output({ systemMessage: "over cap" }); |
| 78 | await handlers.get("session_stop")({ stop_hook_active: false }, context); |
| 79 | assert.deepEqual(notifications, [["over cap", "warning"]]); |
| 80 | }); |
| 81 | |
| 82 | test("the CLI tracks OMP sessions ahead of inherited agent identities", () => { |
| 83 | const roundabout = process.env.ROUNDABOUT_TEST_RABT; |
| 84 | const config = join(temporary, "config.toml"); |
| 85 | writeFileSync(config, '[queue]\nlinger = "1s"\n'); |
| 86 | const env = { |
| 87 | ...process.env, |
| 88 | ROUNDABOUT_CONFIG: config, |
| 89 | ROUNDABOUT_STATE_DIR: join(temporary, "state"), |
| 90 | ROUNDABOUT_RUNTIME_DIR: join(temporary, "run"), |
| 91 | ROUNDABOUT_SESSION_ID: "omp-session", |
| 92 | CLAUDE_CODE_SESSION_ID: "inherited-claude", |
| 93 | CODEX_THREAD_ID: "inherited-codex", |
| 94 | }; |
| 95 | function run(args, payload) { |
| 96 | const result = spawnSync(roundabout, args, { env, input: payload, encoding: "utf8", timeout: 10000 }); |
| 97 | assert.equal(result.status, 0, result.stderr); |
| 98 | return result.stdout; |
| 99 | } |
| 100 | run(["expect", "-s", "s"]); |
| 101 | assert.equal(JSON.parse(run(["status", "--json"])).intents[0].session, "omp-session"); |
| 102 | run(["hook", "omp-session-end"], JSON.stringify({ session_id: "omp-session" })); |
| 103 | assert.equal(JSON.parse(run(["status", "--json"])).intents.length, 0); |
| 104 | run(["expect", "--session", "explicit", "-s", "s"]); |
| 105 | assert.equal(JSON.parse(run(["status", "--json"])).intents[0].session, "explicit"); |
| 106 | run(["expect", "--session", "explicit", "--done"]); |
| 107 | }); |