diff --git a/packages/omo-codex/plugin/components/start-work-continuation/test/boulder-reader.test.ts b/packages/omo-codex/plugin/components/start-work-continuation/test/boulder-reader.test.ts new file mode 100644 index 000000000..f4ba8739a --- /dev/null +++ b/packages/omo-codex/plugin/components/start-work-continuation/test/boulder-reader.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; + +import { parsePlanChecklist } from "../src/boulder-reader.js"; + +describe("start-work plan checklist parser", () => { + it("#given top-level completed and incomplete checkboxes #when parsed #then counts remaining and total", () => { + // given + const markdown = ["# Plan", "", "## TODOs", "- [ ] First", "- [x] Done", "- [X] Also done", "- [ ] Second"].join( + "\n", + ); + + // when + const checklist = parsePlanChecklist(markdown); + + // then + expect(checklist).toEqual({ remaining: 2, total: 4, nextTaskLabel: "First" }); + }); + + it("#given nested checkboxes #when parsed #then ignores non-column-zero items", () => { + // given + const markdown = ["## TODOs", "- [ ] Top-level", " - [ ] Nested", "\t- [ ] Tab nested", "- [x] Complete"].join( + "\n", + ); + + // when + const checklist = parsePlanChecklist(markdown); + + // then + expect(checklist).toEqual({ remaining: 1, total: 2, nextTaskLabel: "Top-level" }); + }); + + it("#given checkboxes outside counted sections #when parsed #then ignores unrelated top-level tasks", () => { + // given + const markdown = [ + "# Plan", + "- [ ] Preamble task", + "## TODOs", + "- [ ] Build hook", + "## Acceptance Criteria", + "- [ ] Acceptance item", + "## Final Verification Wave", + "- [x] Run tests", + "- [ ] Run smoke", + ].join("\n"); + + // when + const checklist = parsePlanChecklist(markdown); + + // then + expect(checklist).toEqual({ remaining: 2, total: 3, nextTaskLabel: "Build hook" }); + }); + + it("#given all top-level tasks complete #when parsed #then next task is null", () => { + // given + const markdown = ["## TODOs", "- [x] First", "- [X] Second"].join("\n"); + + // when + const checklist = parsePlanChecklist(markdown); + + // then + expect(checklist).toEqual({ remaining: 0, total: 2, nextTaskLabel: null }); + }); +}); diff --git a/packages/omo-codex/plugin/components/start-work-continuation/test/cli.test.ts b/packages/omo-codex/plugin/components/start-work-continuation/test/cli.test.ts new file mode 100644 index 000000000..6d6fd65d2 --- /dev/null +++ b/packages/omo-codex/plugin/components/start-work-continuation/test/cli.test.ts @@ -0,0 +1,124 @@ +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { execPath } from "node:process"; +import { afterEach, describe, expect, it } from "vitest"; + +const cleanupRoots: string[] = []; + +afterEach(() => { + for (const root of cleanupRoots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("start-work continuation CLI", () => { + it("#given valid Stop stdin #when CLI runs #then stdout contains block JSON", () => { + // given + const cwd = createWorkspace(["codex:s1"]); + const payload = JSON.stringify(makePayload(cwd, false, "Stop")); + + // when + const result = runCli("stop", payload); + + // then + if (result.error !== undefined) throw result.error; + expect(result.status).toBe(0); + expect(result.stdout).toContain('"decision":"block"'); + }); + + it("#given valid SubagentStop stdin #when CLI runs #then stdout contains block JSON", () => { + // given + const cwd = createWorkspace(["codex:s1"]); + const payload = JSON.stringify(makePayload(cwd, false, "SubagentStop")); + + // when + const result = runCli("subagent-stop", payload); + + // then + if (result.error !== undefined) throw result.error; + expect(result.status).toBe(0); + expect(result.stdout).toContain('"decision":"block"'); + }); + + it("#given active stop hook stdin #when CLI runs #then stdout is empty and exit is zero", () => { + // given + const cwd = createWorkspace(["codex:s1"]); + const payload = JSON.stringify(makePayload(cwd, true, "Stop")); + + // when + const result = runCli("stop", payload); + + // then + if (result.error !== undefined) throw result.error; + expect(result.status).toBe(0); + expect(result.stdout).toBe(""); + }); + + it("#given unrelated session stdin #when CLI runs #then stdout is empty and exit is zero", () => { + // given + const cwd = createWorkspace(["codex:other"]); + const payload = JSON.stringify(makePayload(cwd, false, "Stop")); + + // when + const result = runCli("stop", payload); + + // then + if (result.error !== undefined) throw result.error; + expect(result.status).toBe(0); + expect(result.stdout).toBe(""); + }); + + it("#given malformed stdin #when CLI runs #then stdout is empty and exit is zero", () => { + // given + const payload = "{not-json"; + + // when + const result = runCli("stop", payload); + + // then + if (result.error !== undefined) throw result.error; + expect(result.status).toBe(0); + expect(result.stdout).toBe(""); + }); +}); + +function runCli(subcommand: "stop" | "subagent-stop", input: string) { + return spawnSync(execPath, [join(process.cwd(), "dist", "cli.js"), "hook", subcommand], { input, encoding: "utf8" }); +} + +function createWorkspace(sessionIds: readonly string[]): string { + const root = mkdtempSync(join(tmpdir(), "codex-continuation-cli-")); + cleanupRoots.push(root); + mkdirSync(join(root, ".omo", "plans"), { recursive: true }); + writeFileSync(join(root, ".omo", "plans", "plan.md"), "## TODOs\n\n- [ ] Task one\n"); + const work = { + work_id: "w1", + active_plan: ".omo/plans/plan.md", + plan_name: "cli plan", + session_ids: sessionIds, + status: "active", + }; + writeFileSync( + join(root, ".omo", "boulder.json"), + `${JSON.stringify({ schema_version: 2, active_work_id: "w1", works: { w1: work } })}\n`, + ); + return root; +} + +function makePayload( + cwd: string, + stopHookActive: boolean, + eventName: "Stop" | "SubagentStop", +): Record { + return { + session_id: "s1", + turn_id: "t1", + transcript_path: "", + cwd, + hook_event_name: eventName, + model: "gpt-5.5", + permission_mode: "default", + stop_hook_active: stopHookActive, + last_assistant_message: "done", + }; +} diff --git a/packages/omo-codex/plugin/components/start-work-continuation/test/codex-hook.test.ts b/packages/omo-codex/plugin/components/start-work-continuation/test/codex-hook.test.ts new file mode 100644 index 000000000..afa95f502 --- /dev/null +++ b/packages/omo-codex/plugin/components/start-work-continuation/test/codex-hook.test.ts @@ -0,0 +1,160 @@ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { runStopHook } from "../src/codex-hook.js"; +import type { ReadonlyFileSystem, StopInput } from "../src/types.js"; + +const WORKSPACE = "/repo"; +const BOULDER_PATH = join(WORKSPACE, ".omo", "boulder.json"); +const PLAN_PATH = join(WORKSPACE, ".omo", "plans", "plan.md"); +const LEDGER_PATH = join(WORKSPACE, ".omo", "start-work", "ledger.jsonl"); + +describe("start-work Stop hook", () => { + it("#given stop hook is already active #when hook runs #then returns empty output", () => { + // given + const fs = createMemoryFs(); + const input = { ...createStopInput(), stop_hook_active: true }; + + // when + const output = runStopHook(input, fs); + + // then + expect(output).toBe(""); + }); + + it("#given active codex work with remaining top-level tasks #when hook runs #then returns block JSON", () => { + // given + const fs = createMemoryFs({ + [BOULDER_PATH]: createBoulderJson({ + sessionIds: ["codex:sess_abc"], + status: "active", + worktreePath: "/tmp/worktree", + }), + [PLAN_PATH]: ["# Plan", "", "## TODOs", "- [ ] First", "- [x] Done", "- [ ] Second"].join("\n"), + }); + + // when + const output = runStopHook(createStopInput(), fs); + + // then + const parsed = parseBlockOutput(output); + expect(parsed.decision).toBe("block"); + expect(parsed.reason).toContain("- Plan: `launch-plan`"); + expect(parsed.reason).toContain(`- Plan file: \`${PLAN_PATH}\``); + expect(parsed.reason).toContain(`- Boulder state: \`${BOULDER_PATH}\``); + expect(parsed.reason).toContain("- Remaining top-level checkboxes: `2` of `3`"); + expect(parsed.reason).toContain("- Next incomplete task: `First`"); + expect(parsed.reason).toContain("- Worktree: `/tmp/worktree`"); + expect(parsed.reason).toContain(`- Ledger: \`${LEDGER_PATH}\``); + expect(parsed.reason).toContain("- Your session id in boulder.json: `codex:sess_abc`"); + }); + + it("#given active work belongs to another harness #when hook runs #then returns empty output", () => { + // given + const fs = createMemoryFs({ + [BOULDER_PATH]: createBoulderJson({ sessionIds: ["opencode:sess_abc"], status: "active" }), + [PLAN_PATH]: "- [ ] First", + }); + + // when + const output = runStopHook(createStopInput(), fs); + + // then + expect(output).toBe(""); + }); + + it("#given bare legacy session id #when hook runs #then returns empty output", () => { + // given + const fs = createMemoryFs({ + [BOULDER_PATH]: createBoulderJson({ sessionIds: ["sess_abc"], status: "active" }), + [PLAN_PATH]: "- [ ] First", + }); + + // when + const output = runStopHook(createStopInput(), fs); + + // then + expect(output).toBe(""); + }); + + it("#given completed boulder work #when hook runs #then returns empty output", () => { + // given + const fs = createMemoryFs({ + [BOULDER_PATH]: createBoulderJson({ sessionIds: ["codex:sess_abc"], status: "completed" }), + [PLAN_PATH]: "- [ ] First", + }); + + // when + const output = runStopHook(createStopInput(), fs); + + // then + expect(output).toBe(""); + }); + + it("#given malformed input #when hook runs #then returns empty output", () => { + // given + const fs = createMemoryFs(); + + // when + const output = runStopHook({ hook_event_name: "Stop", session_id: 123 }, fs); + + // then + expect(output).toBe(""); + }); +}); + +type BoulderInput = { + readonly sessionIds: readonly string[]; + readonly status: "active" | "completed" | "paused" | "abandoned"; + readonly worktreePath?: string; +}; + +function createStopInput(): StopInput { + return { + hook_event_name: "Stop", + session_id: "sess_abc", + turn_id: "turn_1", + transcript_path: "", + cwd: WORKSPACE, + model: "gpt-5.5", + permission_mode: "default", + stop_hook_active: false, + last_assistant_message: "done", + }; +} + +function createBoulderJson(input: BoulderInput): string { + const work = { + work_id: "work_1", + active_plan: ".omo/plans/plan.md", + plan_name: "launch-plan", + status: input.status, + session_ids: input.sessionIds, + ...(input.worktreePath === undefined ? {} : { worktree_path: input.worktreePath }), + }; + return JSON.stringify({ schema_version: 2, active_work_id: "work_1", works: { work_1: work } }); +} + +function createMemoryFs(files: Record = {}): ReadonlyFileSystem { + return { + readFileSync(path, encoding) { + expect(encoding).toBe("utf8"); + const value = files[path]; + if (value === undefined) throw new Error(`Missing fixture: ${path}`); + return value; + }, + }; +} + +function parseBlockOutput(output: string): { readonly decision: "block"; readonly reason: string } { + const parsed: unknown = JSON.parse(output); + if (!isRecord(parsed)) throw new Error("Expected object output"); + if (parsed["decision"] !== "block") throw new Error("Expected block decision"); + const reason = parsed["reason"]; + if (typeof reason !== "string") throw new Error("Expected string reason"); + return { decision: "block", reason }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +}