mirror of
https://github.com/wassname/pi-goals.git
synced 2026-09-11 12:43:57 +08:00
Show manual completion claims and wake supervision on plan edits
This commit is contained in:
+95
-9
@@ -15,7 +15,7 @@
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, type FSWatcher, mkdirSync, readdirSync, readFileSync, rmSync, watch, writeFileSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
@@ -47,6 +47,8 @@ const PLAN_MODE_BLOCKED_TOOLS = ["edit", "write"];
|
||||
const SUBTASK_LINE = /^\s+(?:\d+\.|[-*])\s*\[([ xX/-])\]\s*(.*)$/;
|
||||
type GoalStatus = "open" | "active" | "done" | "cancelled";
|
||||
const CHAR_TO_STATUS: Record<string, GoalStatus> = { " ": "open", "/": "active", x: "done", "-": "cancelled" };
|
||||
const STATUS_TO_CHAR: Record<GoalStatus, string> = { open: " ", active: "/", done: "x", cancelled: "-" };
|
||||
const goalKey = (subject: string) => subject.trim().toLowerCase();
|
||||
|
||||
function scanGoals(plan: string): Array<{ status: GoalStatus; subject: string; line: number }> {
|
||||
const goals: Array<{ status: GoalStatus; subject: string; line: number }> = [];
|
||||
@@ -92,6 +94,8 @@ interface PlanState {
|
||||
approvalId: string | null;
|
||||
planVersion: number | null;
|
||||
latestDirection: string;
|
||||
signedOffGoals: string[];
|
||||
previousPlan: string | null;
|
||||
}
|
||||
|
||||
export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
@@ -113,6 +117,8 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
approvalId: null,
|
||||
planVersion: null,
|
||||
latestDirection: "",
|
||||
signedOffGoals: [],
|
||||
previousPlan: null,
|
||||
};
|
||||
let modelError: string | null = null;
|
||||
let readyAttempt: object | undefined;
|
||||
@@ -137,6 +143,31 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
pi.appendEntry<PlanState>(STATE, state);
|
||||
}
|
||||
|
||||
// Only CompleteGoal adds sign-off; direct edits remain claims for supervisor judgment.
|
||||
function refreshSignoffs(ctx: ExtensionContext): void {
|
||||
if (state.phase !== "working") return;
|
||||
const goals = scanGoals(readPlan(ctx));
|
||||
const signedOffGoals = state.signedOffGoals.filter(subject => {
|
||||
const matches = goals.filter(goal => goalKey(goal.subject) === subject);
|
||||
return matches.length === 1 && matches[0].status === "done";
|
||||
});
|
||||
if (signedOffGoals.length !== state.signedOffGoals.length) {
|
||||
state = { ...state, signedOffGoals };
|
||||
persist();
|
||||
}
|
||||
}
|
||||
|
||||
function planReview(plan: string): string {
|
||||
const goals = scanGoals(plan);
|
||||
const previous = scanGoals(state.previousPlan ?? "");
|
||||
const changes = goals.flatMap(goal => {
|
||||
const old = previous.find(prior => goalKey(prior.subject) === goalKey(goal.subject));
|
||||
return old?.status === goal.status ? [] : [`${goal.subject}: ${old ? `[${STATUS_TO_CHAR[old.status]}]` : "not previously observed"} -> [${STATUS_TO_CHAR[goal.status]}]${goal.status === "done" ? state.signedOffGoals.includes(goalKey(goal.subject)) ? "; CompleteGoal sign-off recorded" : "; manual completion claim, no CompleteGoal sign-off recorded" : ""}`];
|
||||
});
|
||||
const claims = goals.filter(goal => goal.status === "done" && !state.signedOffGoals.includes(goalKey(goal.subject)));
|
||||
return `Claims awaiting supervisor judgment: ${claims.map(goal => goal.subject).join(", ") || "none"}\nGoal-state changes:\n${changes.join("\n") || "none"}\nPlan diff since the previous published view:\n${planDiff(state.previousPlan ?? "", plan)}\nManual edits are allowed. Inspect changes and steer a correction when warranted; a checkbox is not sign-off.`;
|
||||
}
|
||||
|
||||
function pauseReason(): string | null {
|
||||
if (!state.phase) return null;
|
||||
if (modelError) return `${modelError} Select /model, then run /goals reconnect.`;
|
||||
@@ -214,22 +245,31 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
let workerTurns = 0;
|
||||
let viewGeneration = 0;
|
||||
let viewTimer: ReturnType<typeof setInterval> | undefined;
|
||||
let planWatcher: FSWatcher | undefined;
|
||||
let planEditTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
async function publishWorkerView(ctx: ExtensionContext, reason: "ready" | "settled" | "turns" | "interval" | "started"): Promise<void> {
|
||||
async function publishWorkerView(ctx: ExtensionContext, reason: "ready" | "settled" | "turns" | "interval" | "started" | "plan"): Promise<void> {
|
||||
if (state.phase !== "working" || modelError || !intercom.bound) return;
|
||||
const generation = ++viewGeneration;
|
||||
const binding = state.approvalId;
|
||||
const background = reason === "started" ? { quiet: false, description: "agent starting; background state not queried" } : await backgroundState(pi);
|
||||
if (!intercom.bound || modelError || generation !== viewGeneration || binding !== state.approvalId || state.phase !== "working") return;
|
||||
refreshSignoffs(ctx);
|
||||
const plan = readPlan(ctx);
|
||||
const entries = ctx.sessionManager.getBranch();
|
||||
const view = workerView(entries, reason, reason !== "started" && ctx.isIdle(), {
|
||||
sourceSession: ctx.sessionManager.getSessionFile()!, latestDirection: state.latestDirection,
|
||||
model: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "not selected",
|
||||
since: intercom.acknowledgedEntry, background: background.description,
|
||||
planReview: `Plan: ${planRel(ctx)}\n${planReview(plan)}`,
|
||||
});
|
||||
intercom.view(view, reason, entries.at(-1)?.id, background.quiet);
|
||||
const goals = scanGoals(readPlan(ctx));
|
||||
if (goals.length > 0 && goals.every((goal) => goal.status === "done" || goal.status === "cancelled")) {
|
||||
if (reason !== "started" && intercom.connected && state.previousPlan !== plan) {
|
||||
state = { ...state, previousPlan: plan };
|
||||
persist();
|
||||
}
|
||||
const goals = scanGoals(plan);
|
||||
if (goals.length > 0 && goals.every((goal) => (goal.status === "done" && state.signedOffGoals.includes(goalKey(goal.subject))) || goal.status === "cancelled")) {
|
||||
stopWorkerTimers();
|
||||
state = { ...state, phase: null };
|
||||
models.leave();
|
||||
@@ -239,12 +279,37 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
}
|
||||
|
||||
function startWorkerTimers(ctx: ExtensionContext): void {
|
||||
if (!planWatcher) {
|
||||
const activePath = planPath(ctx);
|
||||
try {
|
||||
// Watch the containing directory so atomic replacement does not lose the file watch.
|
||||
planWatcher = watch(join(ctx.cwd, PLAN_DIR), (_event, filename) => {
|
||||
if (intercom.ended || state.phase !== "working") return;
|
||||
if (filename && join(ctx.cwd, PLAN_DIR, filename.toString()) !== activePath) return;
|
||||
if (planEditTimer) clearTimeout(planEditTimer);
|
||||
planEditTimer = setTimeout(() => {
|
||||
planEditTimer = undefined;
|
||||
if (intercom.ended || state.phase !== "working" || planPath(ctx) !== activePath) return;
|
||||
updateWidget(ctx);
|
||||
// Working edits coalesce into the existing settled view; idle edits wake review now.
|
||||
if (ctx.isIdle() && readPlan(ctx) !== state.previousPlan) {
|
||||
void publishWorkerView(ctx, "plan").catch(error => { if (!intercom.ended) ctx.ui.notify(`Plan review failed: ${String(error)}`, "error"); });
|
||||
}
|
||||
}, 150);
|
||||
});
|
||||
planWatcher.on("error", error => { if (!intercom.ended) ctx.ui.notify(`Plan watch failed: ${error.message}`, "error"); });
|
||||
} catch (error) { ctx.ui.notify(`Could not watch active plan: ${String(error)}`, "warning"); }
|
||||
}
|
||||
if (!viewTimer) viewTimer = setInterval(() => {
|
||||
void publishWorkerView(ctx, "interval").catch(error => { if (!intercom.ended) ctx.ui.notify(`Worker view failed: ${String(error)}`, "error"); });
|
||||
}, 60 * 60_000);
|
||||
}
|
||||
|
||||
function stopWorkerTimers(): void {
|
||||
planWatcher?.close();
|
||||
planWatcher = undefined;
|
||||
if (planEditTimer) clearTimeout(planEditTimer);
|
||||
planEditTimer = undefined;
|
||||
if (viewTimer) clearInterval(viewTimer);
|
||||
viewTimer = undefined;
|
||||
}
|
||||
@@ -265,6 +330,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
}
|
||||
|
||||
function updateWidget(ctx: ExtensionContext): void {
|
||||
refreshSignoffs(ctx);
|
||||
const paused = pauseReason();
|
||||
if (paused) {
|
||||
ctx.ui.setStatus(STATUS_KEY, ctx.ui.theme.fg("warning", "goals paused"));
|
||||
@@ -282,16 +348,19 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
ctx.ui.setWidget(WIDGET_KEY, undefined);
|
||||
return;
|
||||
}
|
||||
const done = goals.filter((g) => g.status === "done").length;
|
||||
const liveGoals = goals.filter((g) => g.status === "active" || g.status === "open");
|
||||
const stateLabel = liveGoals.length > 0 ? " · supervised" : " · complete";
|
||||
const isSignedOff = (subject: string) => state.signedOffGoals.includes(goalKey(subject));
|
||||
const done = goals.filter(g => g.status === "done" && isSignedOff(g.subject)).length;
|
||||
const claimed = goals.filter(g => g.status === "done" && !isSignedOff(g.subject));
|
||||
const liveGoals = goals.filter(g => g.status === "active" || g.status === "open");
|
||||
const stateLabel = claimed.length ? ` · ${claimed.length} claimed, awaiting review` : liveGoals.length > 0 ? " · supervised" : " · complete";
|
||||
ctx.ui.setStatus(STATUS_KEY, ctx.ui.theme.fg("accent", `◷ ${done}/${goals.length} goals${stateLabel}`));
|
||||
const mark: Record<GoalStatus, string> = { done: "✔", active: "▸", open: "◻", cancelled: "✗" };
|
||||
// Only live goals get lines so finished work never pushes current work off screen. The active
|
||||
// goal also shows its open subtasks: this file is the task list, so the widget is the task list.
|
||||
// No path line: the session id makes it too long to be useful in the widget.
|
||||
const plan = readPlan(ctx);
|
||||
const lines: string[] = liveGoals.length === 0 ? ["✔ complete"] : [];
|
||||
const lines: string[] = claimed.map(g => `? claimed complete; awaiting supervisor review: ${g.subject}`);
|
||||
if (liveGoals.length === 0 && claimed.length === 0) lines.push("✔ complete");
|
||||
for (const g of liveGoals) {
|
||||
lines.push(`${mark[g.status]} ${g.status === "active" ? "supervising… " : ""}${g.subject}`);
|
||||
if (g.status === "active") lines.push(...openSubtasks(plan, g.line).slice(0, 3).map((s) => ctx.ui.theme.fg("muted", ` ◦ ${s}`)));
|
||||
@@ -375,7 +444,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
return;
|
||||
}
|
||||
await restoreModel("planning", ctx);
|
||||
state = { ...state, phase: "planning", supervisorPaneId: null, approvalId: null, planVersion: nextVersion(ctx), latestDirection: arg };
|
||||
state = { ...state, phase: "planning", supervisorPaneId: null, approvalId: null, planVersion: nextVersion(ctx), latestDirection: arg, signedOffGoals: [], previousPlan: null };
|
||||
planningContextPending = true;
|
||||
resyncReason = null;
|
||||
writePlan(ctx, "");
|
||||
@@ -567,6 +636,8 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
approvalId: last?.data?.approvalId ?? null,
|
||||
planVersion: last?.data?.planVersion ?? null,
|
||||
latestDirection: last?.data?.latestDirection ?? "",
|
||||
signedOffGoals: last?.data?.signedOffGoals ?? [],
|
||||
previousPlan: last?.data?.previousPlan ?? null,
|
||||
};
|
||||
modelError = state.phase ? "Role model restoration is pending." : null;
|
||||
planningContextPending = state.phase === "planning";
|
||||
@@ -633,6 +704,8 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
const ticked = tickGoal(plan, params.goal);
|
||||
if (!ticked) return result(`No unique exact goal line matched "${params.goal}" in ${planRel(ctx)}.`, true);
|
||||
writePlan(ctx, appendLog(ticked, `${stamp()} mechanically signed off "${params.goal}" after matching supervisor approval`));
|
||||
state = { ...state, signedOffGoals: [...state.signedOffGoals.filter(goal => goal !== goalKey(params.goal)), goalKey(params.goal)] };
|
||||
persist();
|
||||
updateWidget(ctx);
|
||||
return result(`Sign-off accepted. Goal ticked [x] in ${planRel(ctx)}.`);
|
||||
},
|
||||
@@ -641,6 +714,19 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
|
||||
// --- helpers (module scope) --------------------------------------------------------------------
|
||||
|
||||
// A compact changed span, not a second plan parser. Worker views bound its serialized size.
|
||||
function planDiff(before: string, after: string): string {
|
||||
if (before === after) return "none";
|
||||
const old = before.split("\n");
|
||||
const next = after.split("\n");
|
||||
let start = 0;
|
||||
while (start < old.length && start < next.length && old[start] === next[start]) start++;
|
||||
let oldEnd = old.length;
|
||||
let nextEnd = next.length;
|
||||
while (oldEnd > start && nextEnd > start && old[oldEnd - 1] === next[nextEnd - 1]) { oldEnd--; nextEnd--; }
|
||||
return [`@@ from line ${start + 1} @@`, ...old.slice(start, oldEnd).map(line => `- ${line}`), ...next.slice(start, nextEnd).map(line => `+ ${line}`)].join("\n");
|
||||
}
|
||||
|
||||
function result(text: string, isError = false) {
|
||||
return { content: [{ type: "text" as const, text }], details: {}, isError };
|
||||
}
|
||||
|
||||
+3
-2
@@ -53,9 +53,10 @@ export interface ViewContext {
|
||||
model: string;
|
||||
since?: string;
|
||||
background: string;
|
||||
planReview?: string;
|
||||
}
|
||||
|
||||
export function workerView(entries: SessionEntry[], reason: "ready" | "settled" | "turns" | "interval" | "started", idle: boolean, context: ViewContext): string {
|
||||
export function workerView(entries: SessionEntry[], reason: "ready" | "settled" | "turns" | "interval" | "started" | "plan", idle: boolean, context: ViewContext): string {
|
||||
const compactAt = entries.map(entry => entry.type).lastIndexOf("compaction");
|
||||
const since = context.since ? entries.findIndex(entry => entry.id === context.since) : -1;
|
||||
const from = since >= compactAt ? since + 1 : compactAt + 1;
|
||||
@@ -64,5 +65,5 @@ export function workerView(entries: SessionEntry[], reason: "ready" | "settled"
|
||||
const summary = since < compactAt ? entries[compactAt]?.summary : undefined;
|
||||
const outstanding = outstandingTools(entries.slice(compactAt + 1));
|
||||
const state = reason === "ready" ? "is ready to begin" : idle ? "stopped" : "is still working";
|
||||
return `The worker ${state}.\n\nreview trigger: ${reason}\nsource session: ${bounded(context.sourceSession, 800)}\nworker model: ${bounded(context.model, 300)}\nlatest human direction:\n${bounded(context.latestDirection || "not recorded", 1800)}\ntool calls with no result: ${bounded(outstanding.join(", ") || "none", 500)}\ntracked background work: ${bounded(context.background, 800)}\n\n${summary ? `compaction summary (worker account, not independent evidence):\n${bounded(summary, 2500)}\n\n` : ""}new worker transcript${since === -1 ? " (initial or reset view)" : " since the last acknowledged view"}:\n${bounded(recent || "No new messages.", 7000, true)}`;
|
||||
return `The worker ${state}.\n\nreview trigger: ${reason}\nsource session: ${bounded(context.sourceSession, 800)}\nworker model: ${bounded(context.model, 300)}\nlatest human direction:\n${bounded(context.latestDirection || "not recorded", 1800)}\ntool calls with no result: ${bounded(outstanding.join(", ") || "none", 500)}\ntracked background work: ${bounded(context.background, 800)}\n\n${context.planReview ? `Plan review:\n${bounded(context.planReview, 1800)}\n\n` : ""}${summary ? `compaction summary (worker account, not independent evidence):\n${bounded(summary, 2500)}\n\n` : ""}new worker transcript${since === -1 ? " (initial or reset view)" : " since the last acknowledged view"}:\n${bounded(recent || "No new messages.", 5500, true)}`;
|
||||
}
|
||||
|
||||
+79
-12
@@ -1,5 +1,5 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
@@ -90,7 +90,7 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe("/goals flow", () => {
|
||||
it("reports actual idle state, invalidates stopped views on start, and stops completed plans", async () => {
|
||||
it("reports idleness and keeps manual completion claims supervised without reverting edits", async () => {
|
||||
vi.useFakeTimers({ toFake: ["setInterval", "clearInterval"] });
|
||||
const flow = setup(["Ready"]);
|
||||
try {
|
||||
@@ -100,31 +100,96 @@ describe("/goals flow", () => {
|
||||
const views = () => flow.transport.sent.filter(message => message.kind === "view");
|
||||
await vi.advanceTimersByTimeAsync(60 * 60_000);
|
||||
expect(views().at(-1)?.text).toMatch(/^The worker stopped\./);
|
||||
writeFileSync(path, readFileSync(path, "utf8").replace("[ ] goal:", "[/] goal:"));
|
||||
flow.ctx.isIdle.mockReturnValue(false);
|
||||
await flow.hooks.get("agent_start")({}, flow.ctx);
|
||||
expect(views().at(-1)?.text).toMatch(/^The worker is still working\./);
|
||||
await flow.hooks.get("agent_settled")({}, flow.ctx);
|
||||
expect(views().at(-1)?.text).toMatch(/^The worker is still working\./);
|
||||
flow.ctx.isIdle.mockReturnValue(true);
|
||||
writeFileSync(path, readFileSync(path, "utf8").replace("[ ] goal:", "[x] goal:"));
|
||||
writeFileSync(path, readFileSync(path, "utf8").replace("[/] goal:", "[x] goal:"));
|
||||
await flow.hooks.get("agent_settled")({}, flow.ctx);
|
||||
await flow.hooks.get("turn_end")({}, flow.ctx);
|
||||
const count = views().length;
|
||||
expect(flow.entries.at(-1)?.data).toMatchObject({ phase: null });
|
||||
expect(flow.entries.at(-1)?.data).toMatchObject({ phase: "working", signedOffGoals: [] });
|
||||
expect(views().at(-1)?.text).toContain("make the file: [/] -> [x]; manual completion claim, no CompleteGoal sign-off recorded");
|
||||
expect(readFileSync(path, "utf8")).toContain("[x] goal:");
|
||||
expect(flow.ctx.ui.setStatus).toHaveBeenLastCalledWith("pi-goals", expect.stringContaining("0/1 goals · 1 claimed, awaiting review"));
|
||||
expect(flow.ctx.ui.setWidget).toHaveBeenLastCalledWith("pi-goals-widget", [expect.stringContaining("claimed complete; awaiting supervisor review")]);
|
||||
const binding = (flow.entries.at(-1)?.data as any).approvalId;
|
||||
const messageCount = flow.messages.length;
|
||||
flow.transport.receive({ binding, role: "supervisor", kind: "steer", id: "late-completed", text: "Obsolete instruction." });
|
||||
await flow.commands.get("goals").handler("restart", flow.ctx);
|
||||
await flow.commands.get("goals").handler("reconnect", flow.ctx);
|
||||
expect(flow.messages).toHaveLength(messageCount);
|
||||
expect(openSupervisorPane).toHaveBeenCalledTimes(1);
|
||||
flow.transport.receive({ binding, role: "supervisor", kind: "steer", id: "review-claim", text: "Reopen the goal; verify the missing output first." });
|
||||
expect(flow.messages.at(-1)?.content).toBe("[supervisor] Reopen the goal; verify the missing output first.");
|
||||
await flow.hooks.get("session_start")({}, flow.ctx);
|
||||
expect(flow.entries.at(-1)?.data).toMatchObject({ phase: "working", signedOffGoals: [] });
|
||||
await vi.advanceTimersByTimeAsync(60 * 60_000);
|
||||
expect(views()).toHaveLength(count);
|
||||
expect(views().length).toBeGreaterThan(count);
|
||||
} finally {
|
||||
await flow.hooks.get("session_shutdown")();
|
||||
vi.useRealTimers();
|
||||
rmSync(flow.cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
it("wakes review for an external non-checkbox plan edit, including atomic replacement", async () => {
|
||||
const flow = setup(["Ready"]);
|
||||
try {
|
||||
await flow.commands.get("goals").handler("make the file", flow.ctx);
|
||||
const path = approvedPlan(flow.cwd);
|
||||
await flow.hooks.get("agent_settled")({}, flow.ctx);
|
||||
const before = readFileSync(path, "utf8");
|
||||
writeFileSync(`${path}.tmp`, before.replace("output exists", "output contains exact required bytes"));
|
||||
renameSync(`${path}.tmp`, path);
|
||||
await vi.waitFor(() => {
|
||||
const view = flow.transport.sent.filter(message => message.kind === "view").at(-1);
|
||||
expect(view?.reason).toBe("plan");
|
||||
expect(view?.text).toContain("- - discriminator: output exists");
|
||||
expect(view?.text).toContain("+ - discriminator: output contains exact required bytes");
|
||||
});
|
||||
const count = flow.transport.sent.length;
|
||||
await flow.hooks.get("session_shutdown")();
|
||||
writeFileSync(path, before);
|
||||
await new Promise(resolve => setTimeout(resolve, 250));
|
||||
expect(flow.transport.sent).toHaveLength(count);
|
||||
} finally { await flow.hooks.get("session_shutdown")(); rmSync(flow.cwd, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
it("coalesces active-worker plan edits into its settled review", async () => {
|
||||
const flow = setup(["Ready"]);
|
||||
try {
|
||||
await flow.commands.get("goals").handler("make the file", flow.ctx);
|
||||
const path = approvedPlan(flow.cwd);
|
||||
await flow.hooks.get("agent_settled")({}, flow.ctx);
|
||||
flow.ctx.isIdle.mockReturnValue(false);
|
||||
const before = readFileSync(path, "utf8");
|
||||
writeFileSync(path, before.replace("output exists", "intermediate discriminator"));
|
||||
writeFileSync(path, before.replace("output exists", "final discriminator"));
|
||||
await new Promise(resolve => setTimeout(resolve, 250));
|
||||
expect(flow.transport.sent.filter(message => message.kind === "view" && message.reason === "plan")).toHaveLength(0);
|
||||
flow.ctx.isIdle.mockReturnValue(true);
|
||||
await flow.hooks.get("agent_settled")({}, flow.ctx);
|
||||
const view = flow.transport.sent.filter(message => message.kind === "view").at(-1);
|
||||
expect(view?.text).toContain("- - discriminator: output exists");
|
||||
expect(view?.text).toContain("+ - discriminator: final discriminator");
|
||||
expect(view?.text).not.toContain("intermediate discriminator");
|
||||
} finally { await flow.hooks.get("session_shutdown")(); rmSync(flow.cwd, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
it("restores sign-off markers but clears one when a goal is reopened", async () => {
|
||||
const flow = setup([]);
|
||||
try {
|
||||
const path = writePlan(flow.cwd, "1. [x] goal: first\n2. [ ] goal: second\n");
|
||||
flow.entries.push({ type: "custom", customType: "pi-goals-state", data: { phase: "working", approvalId: "binding", planVersion: 1, signedOffGoals: ["first"], previousPlan: readFileSync(path, "utf8") } });
|
||||
await flow.hooks.get("session_start")({}, flow.ctx);
|
||||
expect(flow.ctx.ui.setStatus).toHaveBeenLastCalledWith("pi-goals", expect.stringContaining("1/2 goals"));
|
||||
writeFileSync(path, "1. [/] goal: first\n2. [ ] goal: second\n");
|
||||
await flow.hooks.get("turn_end")({}, flow.ctx);
|
||||
expect(flow.entries.at(-1)?.data).toMatchObject({ signedOffGoals: [] });
|
||||
writeFileSync(path, "1. [x] goal: first\n2. [ ] goal: second\n");
|
||||
await flow.hooks.get("agent_settled")({}, flow.ctx);
|
||||
await flow.hooks.get("turn_end")({}, flow.ctx);
|
||||
expect(flow.ctx.ui.setStatus).toHaveBeenLastCalledWith("pi-goals", expect.stringContaining("0/2 goals · 1 claimed, awaiting review"));
|
||||
} finally { await flow.hooks.get("session_shutdown")(); rmSync(flow.cwd, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
it("preserves drafts, records the interview, and keeps planning read-only", async () => {
|
||||
const flow = setup(["Refine"], ["Keep two columns."]);
|
||||
try {
|
||||
@@ -247,7 +312,9 @@ describe("/goals flow", () => {
|
||||
expect(readFileSync(planPath, "utf8")).toContain("1. [x] goal: make the file");
|
||||
expect(readFileSync(planPath, "utf8")).toContain("1. [ ] goal: make the file");
|
||||
await flow.hooks.get("agent_settled")({}, flow.ctx);
|
||||
expect(flow.entries.at(-1)?.data).toMatchObject({ phase: null });
|
||||
expect(flow.entries.at(-1)?.data).toMatchObject({ phase: null, signedOffGoals: [goal] });
|
||||
expect(flow.ctx.ui.setWidget).toHaveBeenLastCalledWith("pi-goals-widget", ["✔ complete"]);
|
||||
await flow.hooks.get("session_start")({}, flow.ctx);
|
||||
expect(flow.ctx.ui.setWidget).toHaveBeenLastCalledWith("pi-goals-widget", ["✔ complete"]);
|
||||
} finally {
|
||||
rmSync(flow.cwd, { recursive: true, force: true });
|
||||
|
||||
@@ -30,7 +30,7 @@ it("bounds serialized Unicode and quoted logs while marking omissions", () => {
|
||||
const view = workerView([
|
||||
{ id: "compact", type: "compaction", summary: '"\\🧪'.repeat(20_000) },
|
||||
entry("new", '"\\🧪'.repeat(20_000)),
|
||||
], "interval", true, { ...context, latestDirection: "Remote only. ".repeat(3000) });
|
||||
], "interval", true, { ...context, latestDirection: "Remote only. ".repeat(3000), planReview: '"\\🧪'.repeat(20_000) });
|
||||
expect(Buffer.byteLength(JSON.stringify({ binding: "binding", role: "worker", kind: "view", id: "id", text: view }))).toBeLessThan(16_000);
|
||||
expect(view).toContain("[truncated; inspect source session]");
|
||||
expect(view).toContain(context.sourceSession);
|
||||
|
||||
Reference in New Issue
Block a user