// Hermetic tests for the pi /goal extension pure logic (state, parse, settle, store). // Run: node --test test/goal.test.ts import assert from "node:assert/strict"; import { test } from "node:test"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir, homedir } from "node:os"; import { join } from "node:path"; import { checkLimitReached, clearGoal, DEFAULT_MAX_CHECKS, DEFAULT_MAX_NO_PROGRESS_REPORTS, envDefaultMaxChecks, envDefaultMaxNoProgressReports, initialState, pauseGoal, recordCheckInjected, recordReport, resumeGoal, setGoal, validateReport, } from "../lib/state.ts"; import { parseGoalCommand } from "../lib/parse.ts"; import { decideSettle } from "../lib/settle.ts"; import { agentStateDir, loadState, saveState, stateFilePath } from "../lib/store.ts"; // ---------- state machine ---------- test("initialState is status none with default cap", () => { const s = initialState(25); assert.equal(s.status, "none"); assert.equal(s.text, ""); assert.equal(s.checks, 0); assert.equal(s.maxChecks, 25); assert.equal(s.noProgressReports, 0); assert.equal(s.maxNoProgressReports, 3); assert.equal(s.workEventSinceReport, false); assert.equal(DEFAULT_MAX_CHECKS, 25); assert.equal(DEFAULT_MAX_NO_PROGRESS_REPORTS, 3); }); test("setGoal activates with zeroed checks and optional cap", () => { const s = setGoal(initialState(25), "ship the thing"); assert.equal(s.status, "active"); assert.equal(s.text, "ship the thing"); assert.equal(s.checks, 0); assert.equal(s.maxChecks, 25); const capped = setGoal(initialState(25), "another", 7); assert.equal(capped.maxChecks, 7); }); test("pause only from active; keeps goal and reason", () => { const active = setGoal(initialState(25), "goal"); const paused = pauseGoal(active, "stopped by operator"); assert.equal(paused.status, "paused"); assert.equal(paused.pausedReason, "stopped by operator"); assert.equal(paused.text, "goal"); assert.equal(pauseGoal(paused, "again"), paused); // idempotent when not active assert.equal(pauseGoal(initialState(25), "x").status, "none"); }); test("resume only from paused; resets checks and clears reason", () => { const paused = pauseGoal(setGoal(initialState(25), "goal"), "cap"); paused.checks = 9; paused.noProgressReports = 2; paused.workEventSinceReport = true; const resumed = resumeGoal(paused); assert.equal(resumed.status, "active"); assert.equal(resumed.checks, 0); assert.equal(resumed.noProgressReports, 0); assert.equal(resumed.workEventSinceReport, false); assert.equal(resumed.pausedReason, undefined); assert.equal(resumeGoal(initialState(25)).status, "none"); }); test("clearGoal returns to none", () => { const cleared = clearGoal(setGoal(initialState(25), "goal", 9)); assert.equal(cleared.status, "none"); assert.equal(cleared.text, ""); assert.equal(cleared.maxChecks, 9); }); test("report resets consecutive checks; limit and injection counting interlock", () => { let s = setGoal(initialState(25), "goal", 3); assert.equal(checkLimitReached(s), false); s = recordCheckInjected(s); s = recordCheckInjected(s); assert.equal(checkLimitReached(s), false); // 2 of 3 injected, one slot left s = recordCheckInjected(s); assert.equal(checkLimitReached(s), true); // cap exhausted: auto-pause, no injection s = recordReport(s); assert.equal(checkLimitReached(s), false); // any report resets the counter }); // ---------- env default cap ---------- test("environment caps: valid overrides and invalid values fall back", () => { assert.equal(envDefaultMaxChecks({}), 25); assert.equal(envDefaultMaxChecks({ GOAL_MAX_CHECKS: "10" }), 10); assert.equal(envDefaultMaxChecks({ GOAL_MAX_CHECKS: "abc" }), 25); assert.equal(envDefaultMaxChecks({ GOAL_MAX_CHECKS: "0" }), 25); assert.equal(envDefaultMaxChecks({ GOAL_MAX_CHECKS: "" }), 25); assert.equal(envDefaultMaxNoProgressReports({}), 3); assert.equal(envDefaultMaxNoProgressReports({ GOAL_MAX_NO_PROGRESS_REPORTS: "5" }), 5); assert.equal(envDefaultMaxNoProgressReports({ GOAL_MAX_NO_PROGRESS_REPORTS: "0" }), 3); assert.equal(envDefaultMaxNoProgressReports({ GOAL_MAX_NO_PROGRESS_REPORTS: "bad" }), 3); }); // ---------- command parsing ---------- test("parse: verbs and status", () => { assert.deepEqual(parseGoalCommand(""), { kind: "status" }); assert.deepEqual(parseGoalCommand(" "), { kind: "status" }); assert.deepEqual(parseGoalCommand("stop"), { kind: "stop" }); assert.deepEqual(parseGoalCommand("STOP"), { kind: "stop" }); assert.deepEqual(parseGoalCommand("clear"), { kind: "clear" }); assert.deepEqual(parseGoalCommand("resume"), { kind: "resume" }); }); test("parse: set with plain text", () => { assert.deepEqual(parseGoalCommand("ship the thing"), { kind: "set", text: "ship the thing", max: undefined }); assert.deepEqual(parseGoalCommand(" padded "), { kind: "set", text: "padded", max: undefined }); }); test("parse: --max forms attach to set", () => { assert.deepEqual(parseGoalCommand("do it --max 5"), { kind: "set", text: "do it", max: 5 }); assert.deepEqual(parseGoalCommand("--max 5 do it"), { kind: "set", text: "do it", max: 5 }); assert.deepEqual(parseGoalCommand("do it --max=7"), { kind: "set", text: "do it", max: 7 }); assert.deepEqual(parseGoalCommand("--max 40 write the spec"), { kind: "set", text: "write the spec", max: 40, }); }); test("parse: --max errors", () => { assert.equal(parseGoalCommand("--max").kind, "error"); assert.equal(parseGoalCommand("--max abc").kind, "error"); assert.equal(parseGoalCommand("text --max 0").kind, "error"); assert.equal(parseGoalCommand("text --max").kind, "error"); assert.equal(parseGoalCommand("--max 5").kind, "error"); // no goal text assert.equal(parseGoalCommand("text --max 5.0").kind, "error"); }); test("parse: text containing verbs is goal text, not a verb", () => { assert.deepEqual(parseGoalCommand("stop now"), { kind: "set", text: "stop now", max: undefined }); }); test("setGoal without --max uses fresh default, not the previous goal's cap", () => { let s = setGoal(initialState(25), "first", 3); assert.equal(s.maxChecks, 3); s = clearGoal(s); s = setGoal(s, "second"); assert.equal(s.maxChecks, 25); // per-goal --max: no leak from the capped goal s = setGoal(s, "third", 40); assert.equal(s.maxChecks, 40); s = setGoal(s, "fourth"); assert.equal(s.maxChecks, 25); assert.equal(setGoal(initialState(25), "g", undefined).maxChecks, envDefaultMaxChecks({ GOAL_MAX_CHECKS: "" })); }); // ---------- settle decision (abort/error/cap pause paths, AC4 + AC5) ---------- test("settle: normal completion injects, abort and error pause", () => { const s = setGoal(initialState(25), "goal"); assert.deepEqual(decideSettle(s, "toolUse"), { action: "inject" }); assert.deepEqual(decideSettle(s, "stop"), { action: "inject" }); assert.deepEqual(decideSettle(s, undefined), { action: "inject" }); const aborted = decideSettle(s, "aborted"); assert.equal(aborted.action, "pause"); assert.match((aborted as { reason: string }).reason, /aborted/); const errored = decideSettle(s, "error"); assert.equal(errored.action, "pause"); assert.match((errored as { reason: string }).reason, /error/); }); test("settle: cap exhaustion pauses with cap reason", () => { const s = setGoal(initialState(25), "goal", 2); s.checks = 2; const d = decideSettle(s, "stop"); assert.equal(d.action, "pause"); assert.match((d as { reason: string }).reason, /cap of 2/); // an injected-check counter reset by a report makes the cap pass again assert.deepEqual(decideSettle(recordReport(s), "stop"), { action: "inject" }); }); // ---------- report validation (FR3) ---------- test("validateReport: satisfied and blocked require evidence; in_progress does not", () => { assert.match(validateReport("satisfied", "")!, /evidence/); assert.match(validateReport("satisfied", " ")!, /evidence/); assert.match(validateReport("satisfied", undefined)!, /evidence/); assert.match(validateReport("blocked", "")!, /reason/); assert.match(validateReport("blocked", undefined)!, /reason/); assert.equal(validateReport("in_progress", ""), null); assert.equal(validateReport("satisfied", "counted to three"), null); assert.equal(validateReport("blocked", "missing credentials"), null); }); // ---------- store ---------- test("store: round-trip, missing file, corrupt file", () => { const dir = mkdtempSync(join(tmpdir(), "goal-test-")); try { const path = join(dir, "goal-state.json"); assert.deepEqual(loadState(path), initialState()); // missing file -> initial const s = setGoal(initialState(25), "durable goal", 11); s.checks = 4; s.noProgressReports = 2; s.workEventSinceReport = true; s.lastProgressFingerprint = "abc"; s.lastNextAction = "measure gate"; s.activeWait = { owner: "reviewer", watchId: "review-watch", nextCheck: "review arrives" }; saveState(s, path); const loaded = loadState(path); assert.equal(loaded.text, "durable goal"); assert.equal(loaded.status, "active"); assert.equal(loaded.maxChecks, 11); assert.equal(loaded.checks, 4); assert.equal(loaded.noProgressReports, 2); assert.equal(loaded.workEventSinceReport, true); assert.equal(loaded.lastProgressFingerprint, "abc"); assert.equal(loaded.lastNextAction, "measure gate"); assert.equal(loaded.activeWait?.watchId, "review-watch"); writeFileSync(path, "{not json", "utf8"); assert.deepEqual(loadState(path), initialState()); // corrupt -> initial } finally { rmSync(dir, { recursive: true, force: true }); } }); test("store: legacy version-1 active state gains NG8 defaults without losing focus", () => { const dir = mkdtempSync(join(tmpdir(), "goal-test-legacy-v1-")); try { const path = join(dir, "goal-state.legacy-v1.json"); writeFileSync( path, JSON.stringify({ version: 1, text: "preserve this active goal", status: "active", checks: 7, maxChecks: 19, setAt: "2026-08-30T00:00:00.000Z", }), "utf8", ); const state = loadState(path); assert.equal(state.text, "preserve this active goal"); assert.equal(state.status, "active"); assert.equal(state.checks, 7); assert.equal(state.maxChecks, 19); assert.equal(state.noProgressReports, 0); assert.equal(state.maxNoProgressReports, DEFAULT_MAX_NO_PROGRESS_REPORTS); assert.equal(state.workEventSinceReport, false); } finally { rmSync(dir, { recursive: true, force: true }); } }); test("store: normalize repairs bad counters", () => { const dir = mkdtempSync(join(tmpdir(), "goal-test-")); try { const path = join(dir, "goal-state.json"); writeFileSync( path, JSON.stringify({ version: 1, text: "g", status: "active", checks: -3, maxChecks: 0, noProgressReports: -9, maxNoProgressReports: 0, workEventSinceReport: "yes", setAt: "t", }), "utf8", ); const s = loadState(path); assert.equal(s.checks, 0); assert.equal(s.maxChecks, DEFAULT_MAX_CHECKS); assert.equal(s.noProgressReports, 0); assert.equal(s.maxNoProgressReports, DEFAULT_MAX_NO_PROGRESS_REPORTS); assert.equal(s.workEventSinceReport, false); } finally { rmSync(dir, { recursive: true, force: true }); } }); test("store: agent dir resolves from PI_CODING_AGENT_DIR with ~/.pi/agent fallback", () => { assert.equal(agentStateDir({ PI_CODING_AGENT_DIR: "/seat/.pi/agent" }), "/seat/.pi/agent"); assert.equal(agentStateDir({}), join(homedir(), ".pi", "agent")); assert.equal(stateFilePath("/d"), join("/d", "goal-state.json")); }); test("store pauses invalid persisted bounded-wait configuration", () => { const dir = mkdtempSync(join(tmpdir(), "goal-wait-invalid-")); try { const path = join(dir, "state.json"); for (const fields of [ { waitTimeoutSeconds: -1 }, { waitTimeoutSeconds: 60, activeWait: { owner: "operator", deadlineAt: 1e100 } }, { waitTimeoutSeconds: 60, waitWakeUsed: "false" }, { waitTimeoutSeconds: 60, activeWait: { owner: "operator" } }, { waitTimeoutSeconds: 60, activeWait: { owner: "operator", deadlineAt: 123456, wakeSent: true } }, ]) { writeFileSync(path, JSON.stringify({ ...setGoal(initialState(), "output"), ...fields })); assert.equal(loadState(path).status, "paused"); assert.match(loadState(path).pausedReason!, /invalid persisted wait/); } } finally { rmSync(dir, { recursive: true, force: true }); } });