mirror of
https://github.com/wassname/pi-plan.git
synced 2026-09-25 14:00:15 +08:00
Make goals clear a silent reset and show mode-specific actions
This commit is contained in:
@@ -144,13 +144,13 @@ pi -e ./src/index.ts
|
||||
/goals
|
||||
```
|
||||
|
||||
`/goals` opens the action menu. New plan enters plan mode and starts a conversation;
|
||||
`/goals` shows actions for the current mode. Drafts offer Edit, Discuss and Approve. Quit (`exit` or `clear`) backs up the plan beside the original as a `.bak` file, removes this session's goal check-in, and clears goal state without a model call. Worker processes are unchanged; manage them through `/subagents`. New creates a separate draft without overwriting earlier plans.
|
||||
|
||||
## Context delivery
|
||||
|
||||
Startup and successful compaction mark the plan for a fresh read at the next ordinary prompt (`before_agent_start`). Upkeep becomes due after eight unchanged turns, but waits for that same prompt boundary. A full plan refresh replaces pending upkeep; edits, pause, exit and session navigation invalidate obsolete reminders. Failed or cancelled compaction does not schedule another refresh or consume pending upkeep. Missing plans are retried without discarding progress.
|
||||
|
||||
This is deliberately passive on Pi 0.85.1: tool-loop continuations, overflow retries and already-queued user messages keep Pi's existing role and compacted context, without an extra model turn just to repeat the plan. They do **not** receive a newly read plan until ordinary prompt preparation. Pi's `triggerTurn: false` mid-run path can save a message absent from the live request snapshot; steering can instead force an unwanted turn. We use neither path for upkeep. Passive pause/exit notices use `nextTurn`, with immediate UI feedback; stopping remains local and remote termination is unconfirmed.
|
||||
This is deliberately passive on Pi 0.85.1: tool-loop continuations, overflow retries and already-queued user messages keep Pi's existing role and compacted context, without an extra model turn just to repeat the plan. They do **not** receive a newly read plan until ordinary prompt preparation. Pi's `triggerTurn: false` mid-run path can save a message absent from the live request snapshot; steering can instead force an unwanted turn. We use neither path for upkeep. Passive pause notices use `nextTurn`, with immediate UI feedback; stopping remains local and remote termination is unconfirmed. Quit sends no model message.
|
||||
|
||||
## Prompts
|
||||
|
||||
|
||||
+50
-23
@@ -1,8 +1,9 @@
|
||||
// Pi/OpenAI: Plan and supervise in the main chat; delegate implementation to a visible worker.
|
||||
import { createHash } from "node:crypto";
|
||||
import { type FSWatcher, mkdirSync, readFileSync, watch, writeFileSync } from "node:fs";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { existsSync, type FSWatcher, mkdirSync, readFileSync, watch, writeFileSync } from "node:fs";
|
||||
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
||||
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import { CronStorage } from "pi-schedule-prompt/src/storage.js";
|
||||
import { Type } from "typebox";
|
||||
import { foldPlan, GOAL_LINE } from "./plan.js";
|
||||
import { planViews } from "./plan-view.js";
|
||||
@@ -201,17 +202,17 @@ export default function mainSupervisor(pi: ExtensionAPI) {
|
||||
generation++; notice = true; save(); refresh(ctx); watchPlan(ctx);
|
||||
send(`${removeGoalSchedule(ctx.sessionManager.getSessionId())}\n\n${soloNotice(state.plan!)}`);
|
||||
}
|
||||
const help = "/goals new [initial idea] | review | ready | status | stop | resume | solo | exit | attach <plan.md> [solo] | model <model>\n/subagents opens the worker controls. Stop/exit pause this plan locally; worker termination must be confirmed through subagent_kill or its pane. No forced compaction or model switch; the worker pane's own model is chosen with /model in that pane. Hourly check-ins are one session-bound schedule_prompt job; plan-change reviews are the plan-watcher event hook.";
|
||||
async function ready(ctx: ExtensionContext, menu: boolean) {
|
||||
const help = "/goals new [initial idea] | edit | discuss | review | ready | status | stop | resume | solo | attach <plan.md> [solo] | model <model> | quit (exit/clear)\n/subagents opens the worker controls. Stop pauses work. Quit/exit/clear backs up the plan and clears goal state without a model call; worker processes are unchanged. No forced compaction or model switch; the worker pane's own model is chosen with /model in that pane. Hourly check-ins are one session-bound schedule_prompt job; plan-change reviews are the plan-watcher event hook.";
|
||||
async function ready(ctx: ExtensionContext, menu: boolean, edit = false) {
|
||||
if (state.mode !== "planning") { ctx.ui.notify("Ready applies to a draft; use status or resume.", "warning"); return; }
|
||||
const text = planText();
|
||||
const items = goals(text);
|
||||
if (!items.length || new Set(items.map((g) => key(g.subject))).size !== items.length) {
|
||||
if (!edit && (!items.length || new Set(items.map((g) => key(g.subject))).size !== items.length)) {
|
||||
ctx.ui.notify("Write a plan with distinct '- [ ] goal: ...' subjects before Ready.", "warning"); return;
|
||||
}
|
||||
const stamp = generation;
|
||||
if (menu) {
|
||||
const choice = await ctx.ui.select(`Review ${state.plan}`, ["Ready", "Discuss", "Edit", "Cancel"]);
|
||||
if (menu || edit) {
|
||||
const choice = edit ? "Edit" : await ctx.ui.select(`Review ${state.plan}`, ["Ready", "Discuss", "Edit", "Cancel"]);
|
||||
if (stamp !== generation || digest(planText()) !== digest(text)) { ctx.ui.notify("Plan changed during review. Review it again.", "warning"); return; }
|
||||
if (choice === "Discuss") { send(discuss); return; }
|
||||
if (choice === "Edit") {
|
||||
@@ -313,14 +314,24 @@ export default function mainSupervisor(pi: ExtensionAPI) {
|
||||
});
|
||||
|
||||
pi.registerCommand("goals", {
|
||||
description: "Goal plan actions: new, review, ready, status, stop, resume, solo, attach, model, exit",
|
||||
getArgumentCompletions: (prefix) => ["new", "review", "ready", "status", "stop", "resume", "solo", "attach", "model", "exit", "help"].filter((verb) => verb.startsWith(prefix)).map((verb) => ({ value: verb, label: verb })),
|
||||
description: "Goal plan actions: new, edit, discuss, review, ready, status, stop, resume, solo, attach, model, quit (exit/clear)",
|
||||
getArgumentCompletions: (prefix) => ["new", "attach", "edit", "discuss", "review", "ready", "status", "stop", "resume", "solo", "model", "help", "exit", "clear", "quit"].filter((verb) => verb.startsWith(prefix)).map((verb) => ({ value: verb, label: verb })),
|
||||
handler: async (args, ctx) => {
|
||||
try {
|
||||
if (state.child) { ctx.ui.notify("This is the delegated worker. Goal approval belongs to its parent.", "info"); return; }
|
||||
let command = args.trim();
|
||||
if (!command) {
|
||||
const actions = ["status — Show current plan", "new — New plan", "attach — Open an existing plan", "review — Review current plan", "ready — Approve draft", "stop — Pause work", "resume — Continue paused work", "solo — Work in this session", "model — Set worker model", "exit — Leave goal mode", "help — Show commands"];
|
||||
const actions = [
|
||||
...({
|
||||
chat: ["new — New plan", "attach — Open plan…"],
|
||||
planning: ["edit — Edit plan…", "discuss — Discuss changes to the plan", "ready — Approve draft"],
|
||||
supervising: ["review — Check progress", "stop — Pause work"],
|
||||
paused: ["resume — Resume work"],
|
||||
solo: ["stop — Pause work"],
|
||||
})[state.mode],
|
||||
...(["planning", "supervising", "paused"].includes(state.mode) ? ["model — Settings: worker model"] : []),
|
||||
"help — Show commands", "quit — Exit and clear goals (back up plan)",
|
||||
];
|
||||
const before = generation;
|
||||
const choice = await ctx.ui.select("Goal plan actions", actions);
|
||||
if (!choice || before !== generation) return;
|
||||
@@ -331,6 +342,7 @@ export default function mainSupervisor(pi: ExtensionAPI) {
|
||||
command += ` ${value.trim()}`;
|
||||
}
|
||||
}
|
||||
if (command === "quit" || command === "clear") command = "exit";
|
||||
if (command === "help") { ctx.ui.notify(help, "info"); return; }
|
||||
if (command === "status") {
|
||||
refresh(ctx);
|
||||
@@ -346,8 +358,12 @@ export default function mainSupervisor(pi: ExtensionAPI) {
|
||||
].filter(Boolean).join("\n"), "info");
|
||||
return;
|
||||
}
|
||||
if (command === "discuss") {
|
||||
if (state.mode !== "planning") { ctx.ui.notify("Discuss applies to a draft.", "warning"); return; }
|
||||
send(discuss); return;
|
||||
}
|
||||
if (command === "review" && state.mode === "supervising") { notice = true; send(manualReview(state.plan ?? "")); return; }
|
||||
if (command === "review" || command === "ready") { await ready(ctx, command === "review"); return; }
|
||||
if (command === "edit" || command === "review" || command === "ready") { await ready(ctx, command === "review", command === "edit"); return; }
|
||||
if (command === "model" || command.startsWith("model ")) {
|
||||
if (!state.plan || !goals(planText()).length) { ctx.ui.notify("Register a goal plan first.", "warning"); return; }
|
||||
const ref = command.slice("model".length).trim();
|
||||
@@ -384,16 +400,24 @@ export default function mainSupervisor(pi: ExtensionAPI) {
|
||||
else send(attachNotice(target, false, noted));
|
||||
return;
|
||||
}
|
||||
if (command === "stop" || command === "exit") {
|
||||
if (state.mode === "planning") {
|
||||
if (command === "stop") { ctx.ui.notify("A draft cannot pause; use /goals exit to leave planning with the draft preserved.", "warning"); return; }
|
||||
// Planning exit must not get the model trapped re-planning or lose the draft.
|
||||
state.mode = "chat"; generation++; notice = true; save(); refresh(ctx); watchPlan(ctx);
|
||||
ctx.ui.notify(`Planning exited; draft preserved at ${state.plan}. No implementation was approved or started. Reconnect with /goals attach ${state.plan}.`, "info");
|
||||
return;
|
||||
if (command === "exit") {
|
||||
const backup = state.plan && existsSync(state.plan) ? `${state.plan}.${randomUUID()}.bak` : undefined;
|
||||
if (backup) writeFileSync(backup, readFileSync(state.plan!), { flag: "wx" });
|
||||
const storage = new CronStorage(ctx.cwd);
|
||||
const session = ctx.sessionManager.getSessionId();
|
||||
for (const job of storage.getAllJobs().filter(j => j.name === `goals-${session}` && j.session === session)) {
|
||||
storage.removeJob(job.id); // Scheduler re-reads storage before firing; removed jobs cannot prompt.
|
||||
pi.events.emit("cron:change", { type: "remove", jobId: job.id });
|
||||
}
|
||||
state.mode = command === "stop" ? "paused" : "chat"; generation++; notice = true; save(); refresh(ctx); watchPlan(ctx);
|
||||
const pause = pauseExitNotice(state.worker, command === "exit");
|
||||
state = initial(); generation++; workerRevision++; pendingLaunches = 0; pendingUpkeep = undefined; notice = true;
|
||||
save(); refresh(ctx); watchPlan(ctx);
|
||||
ctx.ui.notify(`Goals cleared.${backup ? ` Plan backed up to ${backup}.` : ""}`, "info");
|
||||
return;
|
||||
}
|
||||
if (command === "stop") {
|
||||
if (state.mode === "planning") { ctx.ui.notify("A draft cannot pause; use /goals quit to back up and clear it.", "warning"); return; }
|
||||
state.mode = "paused"; generation++; notice = true; save(); refresh(ctx); watchPlan(ctx);
|
||||
const pause = pauseExitNotice(state.worker, false);
|
||||
const requestCleanup = Boolean(state.worker) || hasScheduleTool();
|
||||
if (!requestCleanup) ctx.ui.notify(pause, "info"); // Visible now; passive model context waits for a prompt.
|
||||
send(`${removeGoalSchedule(ctx.sessionManager.getSessionId())}\n\n${pause}`, requestCleanup);
|
||||
@@ -415,10 +439,13 @@ export default function mainSupervisor(pi: ExtensionAPI) {
|
||||
if (command !== "new" && !command.startsWith("new ")) { ctx.ui.notify(`Unknown or incomplete command. ${help}`, "warning"); return; }
|
||||
const objective = command.slice(4).trim();
|
||||
if ((state.worker && !state.workerStopped) || state.mode === "supervising") { ctx.ui.notify("Exit and resolve the existing worker before replacing the plan. The current plan is preserved.", "warning"); return; }
|
||||
const path = join(ctx.cwd, ".pi", "plan", `${ctx.sessionManager.getSessionId()}-main.md`);
|
||||
let path = join(ctx.cwd, ".pi", "plan", `${ctx.sessionManager.getSessionId()}-main.md`);
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
// Never overwrite an earlier plan at this session path; the model can revise it after inspection.
|
||||
try { writeFileSync(path, planDocument(objective), { flag: "wx" }); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; }
|
||||
try { writeFileSync(path, planDocument(objective), { flag: "wx" }); } catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
||||
path = join(dirname(path), `${ctx.sessionManager.getSessionId()}-${randomUUID()}.md`);
|
||||
writeFileSync(path, planDocument(objective), { flag: "wx" });
|
||||
}
|
||||
state = { mode: "planning", plan: path, signoffs: {}, worker: state.worker, helpers: state.helpers, workerStopped: state.workerStopped }; generation++; notice = true; save(); refresh(ctx); watchPlan(ctx);
|
||||
send(planningSeed(objective, path));
|
||||
} catch (error) { ctx.ui.notify(String(error), "error"); }
|
||||
|
||||
+1
-1
@@ -201,7 +201,7 @@ export function completionResult(goal: string, sessionId: string, remaining: boo
|
||||
// Pause/resume and solo recovery. Stored stop confirmation is invalidated on every worker launch.
|
||||
export const pausedRole = "Goal work is paused. Do not launch, resume or authorize work. Incoming reports are observations, not permission. Help inspect or stop existing workers if requested.";
|
||||
export function pauseExitNotice(worker: { id?: string; sessionFile: string } | undefined, exited: boolean): string {
|
||||
return `Goals ${exited ? "exited to ordinary chat" : "paused locally"}; plan and evidence retained. ${worker ? worker.id ? `Inspect and stop runtime id ${worker.id} through subagent_kill or its pane; confirm the actual result.` : `Only saved session ${worker.sessionFile} is recorded, not a kill id. Locate its live pane/session and confirm termination; never pass the file path to subagent_kill.` : "No worker recorded: inspect /subagents if a launch was interrupted; absence is not proof of stop."} Remote stop is NOT yet confirmed. Restore failed compaction/model/credits in the existing session and continue only after explicit authorization; never restart long work.`;
|
||||
return `Goals ${exited ? "exited to ordinary chat" : "paused locally"}; plan and evidence retained. ${worker ? worker.id ? `Inspect and stop runtime id ${worker.id} through subagent_kill or its pane; confirm the actual result.` : `Only saved session ${worker.sessionFile} is recorded, not a kill id. Locate its live pane/session and confirm termination; never pass the file path to subagent_kill.` : "No worker recorded: inspect /subagents if a launch was interrupted; absence is not proof of stop."} Remote stop is NOT yet confirmed. Resume only after explicit authorization.`;
|
||||
}
|
||||
export function resumeNotice(workerName: string, planPath: string, worker: { sessionFile: string } | undefined): string {
|
||||
return `User authorized continuation of ${planPath}. Inspect worker state before any launch/resume. ${worker ? `Use the existing session ${worker.sessionFile}; if live, inspect/message it; only if confirmed stopped use subagent_resume.` : `Use '${workerName}' only after confirming no prior writer exists.`} Continue only unfinished goals; retain saved progress and scheduler edits.`;
|
||||
|
||||
+82
-12
@@ -1,4 +1,4 @@
|
||||
import { mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { mkdirSync, mkdtempSync, readdirSync, 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";
|
||||
@@ -23,7 +23,7 @@ function fixture(child = false) {
|
||||
const entries: any[] = []; const hooks = new Map<string, any>(); const commands = new Map<string, any>(); const tools = new Map<string, any>();
|
||||
const messages: any[] = [];
|
||||
const ctx = { cwd, sessionManager: { getBranch: () => entries, getSessionId: () => "copy-only" }, hasUI: true, ui: {
|
||||
theme: { fg: (_color: string, text: string) => text }, notify: vi.fn(), setStatus: vi.fn(), setWidget: vi.fn(), select: vi.fn(async () => "Ready"), editor: vi.fn(),
|
||||
theme: { fg: (_color: string, text: string) => text }, notify: vi.fn(), setStatus: vi.fn(), setWidget: vi.fn(), select: vi.fn(async (_title: string, _options: string[]) => "Ready"), editor: vi.fn(),
|
||||
} };
|
||||
const pi = {
|
||||
on: (event: string, hook: any) => hooks.set(event, hook),
|
||||
@@ -32,6 +32,7 @@ function fixture(child = false) {
|
||||
registerTool: (definition: any) => tools.set(definition.name, definition),
|
||||
sendMessage: (message: any, options: any) => messages.push({ message, options }),
|
||||
sendUserMessage: (content: string, options: any) => messages.push({ message: { content }, options, savedPrompt: true }),
|
||||
events: { emit: vi.fn() },
|
||||
getAllTools: vi.fn(() => [
|
||||
{ name: "subagent", parameters: { properties: { agent: {}, title: {} } } },
|
||||
{ name: "subagent_resume", parameters: { properties: { sessionFile: {} } } },
|
||||
@@ -56,12 +57,25 @@ function fixture(child = false) {
|
||||
return { ctx, pi, hooks, tools, commands, messages, command, path, plan, draft, shutdown, changed, atomicWrite, entries };
|
||||
}
|
||||
|
||||
it("shows action choices and autocomplete without starting work", async () => {
|
||||
it.each([
|
||||
["chat", ["new", "attach", "help", "quit"]],
|
||||
["planning", ["edit", "discuss", "ready", "model", "help", "quit"]],
|
||||
["supervising", ["review", "stop", "model", "help", "quit"]],
|
||||
["paused", ["resume", "model", "help", "quit"]],
|
||||
["solo", ["stop", "help", "quit"]],
|
||||
])("shows only applicable %s actions without starting work", async (mode, expected) => {
|
||||
const f = fixture();
|
||||
if (mode !== "chat") await f.draft();
|
||||
if (mode === "supervising" || mode === "paused") await f.command("ready");
|
||||
if (mode === "paused") await f.command("stop");
|
||||
if (mode === "solo") { f.ctx.ui.select.mockResolvedValueOnce("Worker confirmed stopped"); await f.command("solo"); }
|
||||
const before = f.messages.length;
|
||||
f.ctx.ui.select.mockResolvedValueOnce(undefined as any);
|
||||
await f.command("");
|
||||
expect(f.ctx.ui.select).toHaveBeenCalledWith("Goal plan actions", expect.arrayContaining(["new — New plan", "resume — Continue paused work"]));
|
||||
expect(f.messages).toHaveLength(0);
|
||||
const actions = f.ctx.ui.select.mock.calls.at(-1)![1];
|
||||
expect(actions.map(action => action.split(" — ")[0])).toEqual(expected);
|
||||
expect(actions.at(-1)).toBe("quit — Exit and clear goals (back up plan)");
|
||||
expect(f.messages).toHaveLength(before);
|
||||
expect(f.commands.get("goals").getArgumentCompletions("res")).toEqual([{ value: "resume", label: "resume" }]);
|
||||
});
|
||||
|
||||
@@ -92,6 +106,59 @@ it.each(["menu", "command"])("enters planning conversation through %s without an
|
||||
expect(f.hooks.get("tool_call")({ toolName: "subagent" }).block).toBe(true);
|
||||
});
|
||||
|
||||
it("edits even an empty draft directly without a model call", async () => {
|
||||
const f = fixture(); await f.command("new"); const before = f.messages.length;
|
||||
f.ctx.ui.editor.mockResolvedValueOnce(f.plan);
|
||||
f.ctx.ui.select.mockResolvedValueOnce("edit — Edit plan…"); await f.command("");
|
||||
expect(readFileSync(f.path, "utf8")).toBe(f.plan);
|
||||
expect(f.entries.at(-1).data.mode).toBe("planning");
|
||||
expect(f.messages).toHaveLength(before);
|
||||
});
|
||||
|
||||
it("clear backs up the plan, drops stale bindings and allows a separate new draft", async () => {
|
||||
const f = fixture(); await f.draft(); await f.command("ready");
|
||||
f.hooks.get("tool_result")({ toolName: "subagent", details: { id: "stale", sessionFile: "/tmp/old-worker.jsonl" } });
|
||||
const jobs = [
|
||||
{ id: "owned", name: "goals-copy-only", session: "copy-only", enabled: true },
|
||||
{ id: "older", name: "older-plan", session: "copy-only", enabled: true },
|
||||
{ id: "foreign", name: "goals-copy-only", session: "other", enabled: true },
|
||||
{ id: "unbound", name: "goals-copy-only", enabled: true },
|
||||
];
|
||||
const schedule = join(f.ctx.cwd, ".pi/schedule-prompts.json"); writeFileSync(schedule, JSON.stringify({ version: 1, jobs }));
|
||||
const before = f.messages.length; await f.command("clear");
|
||||
expect(f.messages).toHaveLength(before);
|
||||
expect(f.entries.at(-1).data).toEqual({ mode: "chat", helpers: [], signoffs: {} });
|
||||
expect(JSON.parse(readFileSync(schedule, "utf8")).jobs).toEqual(jobs.slice(1));
|
||||
expect(f.pi.events.emit).toHaveBeenCalledWith("cron:change", { type: "remove", jobId: "owned" });
|
||||
const directory = join(f.ctx.cwd, ".pi/plan");
|
||||
const backup = readdirSync(directory).find(name => name.endsWith(".bak"))!;
|
||||
expect(readFileSync(join(directory, backup), "utf8")).toBe(f.plan);
|
||||
await f.command("new a different objective");
|
||||
const next = f.entries.at(-1).data;
|
||||
expect(next.mode).toBe("planning"); expect(next.worker).toBeUndefined(); expect(next.plan).not.toBe(f.path);
|
||||
expect(readFileSync(next.plan, "utf8")).toContain("a different objective");
|
||||
expect(readFileSync(next.plan, "utf8")).not.toContain("first output");
|
||||
expect(readFileSync(f.path, "utf8")).toBe(f.plan);
|
||||
});
|
||||
|
||||
it.each(["missing", "empty"])("clear resets a %s plan without a model call", async kind => {
|
||||
const f = fixture(); await f.draft(); const before = f.messages.length;
|
||||
if (kind === "missing") rmSync(f.path); else writeFileSync(f.path, "");
|
||||
await f.command("clear");
|
||||
expect(f.entries.at(-1).data).toEqual({ mode: "chat", helpers: [], signoffs: {} });
|
||||
expect(f.messages).toHaveLength(before);
|
||||
});
|
||||
|
||||
it("discusses plan changes only during planning", async () => {
|
||||
const f = fixture(); await f.command("discuss"); expect(f.messages).toHaveLength(0);
|
||||
await f.draft();
|
||||
f.ctx.ui.select.mockResolvedValueOnce("discuss — Discuss changes to the plan"); await f.command("");
|
||||
expect(f.messages.at(-1).message.content).toContain("Discuss the current draft");
|
||||
expect(f.entries.at(-1).data.mode).toBe("planning");
|
||||
await f.command("ready"); const before = f.messages.length;
|
||||
await f.command("discuss"); expect(f.messages).toHaveLength(before);
|
||||
});
|
||||
|
||||
it("automatically proposes a changed settled draft once and preserves Discuss", async () => {
|
||||
const f = fixture(); await f.draft();
|
||||
f.ctx.ui.select.mockResolvedValueOnce("Discuss");
|
||||
@@ -241,7 +308,7 @@ it("does not retrigger a review for its own CompleteGoal plan write", async () =
|
||||
f.shutdown();
|
||||
});
|
||||
|
||||
it("gives pause/exit the session-bound scheduler job removal guidance", async () => {
|
||||
it("gives pause scheduler guidance but clears on exit without a model prompt", async () => {
|
||||
const f = fixture(); await f.draft(); await f.command("ready");
|
||||
await f.command("stop");
|
||||
const stop = f.messages.at(-1).message.content;
|
||||
@@ -250,9 +317,10 @@ it("gives pause/exit the session-bound scheduler job removal guidance", async ()
|
||||
expect(stop).not.toContain("interval '1h'");
|
||||
expect(stop).toContain("Remote stop is NOT yet confirmed");
|
||||
await f.command("resume");
|
||||
const before = f.messages.length;
|
||||
await f.command("exit");
|
||||
expect(f.messages.at(-1).message.content).toContain('goals-copy-only"');
|
||||
expect(f.entries.at(-1).data.mode).toBe("chat");
|
||||
expect(f.messages).toHaveLength(before);
|
||||
expect(f.entries.at(-1).data).toEqual({ mode: "chat", helpers: [], signoffs: {} });
|
||||
});
|
||||
|
||||
it("tells the model to remove only its own job after the final review", async () => {
|
||||
@@ -350,13 +418,15 @@ it("rejects attaching a missing or goal-less file", async () => {
|
||||
expect(f.entries).toEqual([]); // nothing saved: the session was not attached
|
||||
});
|
||||
|
||||
it("exits planning with the draft preserved and nothing implemented", async () => {
|
||||
it.each(["exit", "quit", "clear", "menu"])("%s exits planning with the draft preserved and nothing implemented", async command => {
|
||||
const f = fixture(); await f.draft();
|
||||
await f.command("stop");
|
||||
expect(f.ctx.ui.notify).toHaveBeenLastCalledWith(expect.stringContaining("A draft cannot pause"), "warning");
|
||||
const before = f.messages.length;
|
||||
await f.command("exit");
|
||||
if (command === "menu") f.ctx.ui.select.mockResolvedValueOnce("quit — Exit and clear goals (back up plan)");
|
||||
await f.command(command === "menu" ? "" : command);
|
||||
expect(f.entries.at(-1).data.mode).toBe("chat");
|
||||
expect(f.ctx.ui.setWidget).toHaveBeenLastCalledWith("goals", undefined);
|
||||
expect(readFileSync(f.path, "utf8")).toContain("first output");
|
||||
expect(f.messages.length).toBe(before); // notify only, no model turn started
|
||||
f.ctx.ui.select.mockResolvedValueOnce("Previous supervisor confirmed stopped");
|
||||
@@ -687,10 +757,10 @@ it("coalesces pending upkeep with a repaired post-compaction plan, retaining the
|
||||
expect(f.hooks.get("before_agent_start")({ systemPrompt: "base" }, f.ctx).message).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each(["stop", "exit"])("passive %s is visible immediately while its model notice waits safely for the next prompt", async command => {
|
||||
it("passive pause is visible immediately while its model notice waits safely for the next prompt", async () => {
|
||||
const f = fixture(); await f.draft();
|
||||
f.ctx.ui.select.mockResolvedValueOnce("Worker confirmed stopped"); await f.command("solo");
|
||||
await f.command(command);
|
||||
await f.command("stop");
|
||||
expect(f.messages.at(-1).options).toEqual({ deliverAs: "nextTurn" });
|
||||
expect(f.ctx.ui.notify).toHaveBeenLastCalledWith(expect.stringContaining("Remote stop is NOT yet confirmed"), "info");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user