From ec1cc34f735409127a7416f5e1e908d5c0f6efff Mon Sep 17 00:00:00 2001 From: wassname2 Date: Fri, 11 Sep 2026 15:32:47 +0800 Subject: [PATCH] Make goals clear a silent reset and show mode-specific actions --- README.md | 4 +- src/index.ts | 73 +++++++++++++++++++++++------------ src/prompts.ts | 2 +- test/goals.test.ts | 94 ++++++++++++++++++++++++++++++++++++++++------ 4 files changed, 135 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index a9a080a..bb655f9 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/index.ts b/src/index.ts index b21e238..679cef7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 [solo] | 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 [solo] | 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"); } diff --git a/src/prompts.ts b/src/prompts.ts index 40c2766..d08f97d 100644 --- a/src/prompts.ts +++ b/src/prompts.ts @@ -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.`; diff --git a/test/goals.test.ts b/test/goals.test.ts index dfdb8ab..54d4671 100644 --- a/test/goals.test.ts +++ b/test/goals.test.ts @@ -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(); const commands = new Map(); const tools = new Map(); 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"); });