321 lines
12 KiB
TypeScript
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 }); }
|
|
});
|