Files
stack/extensions/goal/test/goal.test.ts
T

321 lines
12 KiB
TypeScript

// 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 }); }
});