mirror of
https://github.com/wassname/pi-plan.git
synced 2026-09-25 14:00:15 +08:00
Collapse goal notices without changing prompt delivery
Render a UI-only copy with Ctrl+O expansion and keep original user prompts unchanged. Verify exact prompt delivery and Ready role transition through real Pi RPC, plus an isolated Herdr display trial. Co-Authored-By: Pi/OpenAI <288921227+claudypoo@users.noreply.github.com>
This commit is contained in:
@@ -153,6 +153,8 @@ pi
|
||||
|
||||
## Context delivery
|
||||
|
||||
New injected `[pi-goals]` prompts display as a compact notice; `Ctrl+O` expands the full text. This changes only the display: the original prompt still reaches the model once through normal role preparation. Older notices without a saved display entry remain expanded. — Pi/OpenAI
|
||||
|
||||
Startup, session restore and successful compaction inject the complete current plan document at the next ordinary prompt. Other ordinary plan-context messages inject the title, introductory paragraph and `## User-visible result`; plan-change and manual-review messages carry that short view directly rather than only a path. After eight unchanged turns, the next ordinary prompt carries a medium view: the short view, verbatim `## User voice`, and goal headings with their checkbox status. It omits task and evidence details. A Log heading at any Markdown heading level starts history for these short and medium views. The complete refresh retains the entire document, including Log history. The first request to complete the final non-cancelled goal queues a full-plan review without recording sign-off; only a second completion request in that review turn can record it. A plan edit invalidates the queued review. Compaction uses Pi's configured threshold; this plugin does not set a separate 150k limit. Supervisor upkeep cycles through curated nudges, advancing only when delivered; the editable hourly `schedule_prompt` check-in is unchanged. 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 notices use `nextTurn`, with immediate UI feedback; stopping remains local and remote termination is unconfirmed. Quit sends no model message.
|
||||
|
||||
+8
-2
@@ -5,6 +5,7 @@ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
||||
import { type ExtensionAPI, type ExtensionContext, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
||||
import { CronStorage } from "pi-schedule-prompt/src/storage.js";
|
||||
import { Type } from "typebox";
|
||||
import { noticeDisplay } from "./notice-display.js";
|
||||
import { FOLD_LINE, foldPlan, GOAL_LINE, goalAcceptanceSignature } from "./plan.js";
|
||||
import { planViews } from "./plan-view.js";
|
||||
import {
|
||||
@@ -72,6 +73,7 @@ function goals(text: string) {
|
||||
const result = (text: string) => ({ content: [{ type: "text" as const, text }], details: {} });
|
||||
|
||||
export default function mainSupervisor(pi: ExtensionAPI) {
|
||||
const notices = noticeDisplay(pi);
|
||||
let state = initial();
|
||||
let generation = 0;
|
||||
let workerRevision = 0;
|
||||
@@ -175,6 +177,7 @@ export default function mainSupervisor(pi: ExtensionAPI) {
|
||||
} catch (error) { ctx.ui.notify(`Plan monitoring unavailable: ${String(error)}`, "error"); }
|
||||
}
|
||||
function restore(ctx: ExtensionContext) {
|
||||
notices.restore(ctx);
|
||||
generation++;
|
||||
state = initial();
|
||||
for (const entry of ctx.sessionManager.getBranch()) {
|
||||
@@ -205,8 +208,11 @@ export default function mainSupervisor(pi: ExtensionAPI) {
|
||||
function send(content: string, triggerTurn = true) {
|
||||
// sendMessage(triggerTurn:true) bypasses before_agent_start in Pi 0.85.1.
|
||||
// A normal saved prompt prepares the current role before starting the turn.
|
||||
if (triggerTurn) pi.sendUserMessage(`[pi-goals]\n${content}`, { deliverAs: "followUp" });
|
||||
else pi.sendMessage({ customType: "pi-goals-supervision", content, display: true }, { deliverAs: "nextTurn" });
|
||||
if (triggerTurn) {
|
||||
const prompt = `[pi-goals]\n${content}`;
|
||||
notices.mirror(prompt);
|
||||
pi.sendUserMessage(prompt, { deliverAs: "followUp" });
|
||||
} else pi.sendMessage({ customType: "pi-goals-supervision", content, display: true }, { deliverAs: "nextTurn" });
|
||||
}
|
||||
async function confirmOwnership(ctx: ExtensionContext, target: string, text: string, solo = true): Promise<boolean> {
|
||||
if (pendingLaunches.size > 0) { ctx.ui.notify("A worker launch/resume is still pending; inspect its result before takeover.", "warning"); return false; }
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// Pi/OpenAI: Collapse only the display; the saved user prompt and model input stay unchanged.
|
||||
import { type ExtensionAPI, type ExtensionContext, getMarkdownTheme, keyHint } from "@earendil-works/pi-coding-agent";
|
||||
import { Markdown, truncateToWidth } from "@earendil-works/pi-tui";
|
||||
|
||||
const NOTICE = "pi-goals-notice";
|
||||
|
||||
export function noticeDisplay(pi: ExtensionAPI) {
|
||||
const mirrored = new Set<string>();
|
||||
pi.registerMarkdownTransformer((markdown, context) =>
|
||||
context.messageType === "user" && mirrored.has(markdown) ? "" : markdown);
|
||||
pi.registerEntryRenderer(NOTICE, (entry, { expanded }, theme) => {
|
||||
const { content } = entry.data as { content: string };
|
||||
const label = content.includes("\nPlan changed.") ? "Plan changed · review requested" : "Goal instructions";
|
||||
if (expanded) return new Markdown(content, 0, 0, getMarkdownTheme());
|
||||
return {
|
||||
render: (width) => [truncateToWidth(theme.fg("muted", `[pi-goals] ${label} · ${keyHint("app.tools.expand", "expand")}`), width)],
|
||||
invalidate() {},
|
||||
};
|
||||
});
|
||||
return {
|
||||
mirror(content: string) {
|
||||
mirrored.add(content);
|
||||
pi.appendEntry(NOTICE, { content });
|
||||
},
|
||||
restore(ctx: ExtensionContext) {
|
||||
mirrored.clear();
|
||||
for (const entry of ctx.sessionManager.getBranch()) {
|
||||
if (entry.type === "custom" && entry.customType === NOTICE) mirrored.add((entry.data as { content: string }).content);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
+7
-3
@@ -31,6 +31,8 @@ function fixture(child = false) {
|
||||
appendEntry: (customType: string, data: any) => entries.push({ type: "custom", customType, data }),
|
||||
registerCommand: (name: string, definition: any) => commands.set(name, definition),
|
||||
registerTool: (definition: any) => tools.set(definition.name, definition),
|
||||
registerMarkdownTransformer: vi.fn(),
|
||||
registerEntryRenderer: vi.fn(),
|
||||
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() },
|
||||
@@ -68,7 +70,7 @@ function fixture(child = false) {
|
||||
start(details.id, { agent, title: "Work", sessionFile: details.sessionFile }, toolName);
|
||||
finish(details.id, details, toolName);
|
||||
};
|
||||
return { ctx, pi, hooks, tools, commands, messages, command, get path() { return path; }, plan, draft, shutdown, changed, atomicWrite, entries, start, finish, launch };
|
||||
return { ctx, pi, hooks, tools, commands, messages, command, get path() { return path; }, plan, draft, shutdown, changed, atomicWrite, get entries() { return entries.filter(entry => entry.customType === "pi-goals-main-supervisor-v1"); }, start, finish, launch };
|
||||
}
|
||||
|
||||
it.each([
|
||||
@@ -905,10 +907,12 @@ it("real SessionManager preserves historical state and restores draft authority
|
||||
f.pi.appendEntry = (type: string, data: unknown) => { session.appendCustomEntry(type, data); return 0; };
|
||||
f.ctx.sessionManager.getBranch = () => session.getBranch();
|
||||
await f.draft();
|
||||
const planned = session.getLeafEntry() as any;
|
||||
const latestState = () => session.getBranch().filter(entry => entry.type === "custom" && entry.customType === "pi-goals-main-supervisor-v1").at(-1) as any;
|
||||
const planned = latestState();
|
||||
await f.command("ready");
|
||||
expect(planned.data.mode).toBe("planning");
|
||||
expect((session.getLeafEntry() as any).data).not.toBe(planned.data);
|
||||
expect(latestState().data).not.toBe(planned.data);
|
||||
expect(latestState().data.mode).toBe("supervising");
|
||||
session.branch(planned.id);
|
||||
f.hooks.get("session_tree")({ newLeafId: planned.id }, f.ctx);
|
||||
expect(f.start("after-tree")?.block).toBe(true);
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { type ExtensionAPI, type ExtensionContext, getMarkdownTheme, initTheme } from "@earendil-works/pi-coding-agent";
|
||||
import { Markdown, visibleWidth } from "@earendil-works/pi-tui";
|
||||
import { expect, it, vi } from "vitest";
|
||||
import { noticeDisplay } from "../src/notice-display.js";
|
||||
|
||||
it("collapses mirrored prompts only in the UI, expands the exact text, and restores by branch", () => {
|
||||
initTheme("dark");
|
||||
const theme = { fg: (_color: string, text: string) => text };
|
||||
const pi = { registerMarkdownTransformer: vi.fn(), registerEntryRenderer: vi.fn(), appendEntry: vi.fn() };
|
||||
const display = noticeDisplay(pi as unknown as ExtensionAPI);
|
||||
const transform = pi.registerMarkdownTransformer.mock.calls[0][0];
|
||||
const render = pi.registerEntryRenderer.mock.calls[0][1];
|
||||
const content = "[pi-goals]\nPlan changed. Inspect the evidence.\n\n# Output\n\nKeep this entire requirement.\n\nFinal evidence line.";
|
||||
expect(transform(content, { messageType: "user" })).toBe(content);
|
||||
display.mirror(content);
|
||||
expect(pi.appendEntry).toHaveBeenCalledExactlyOnceWith("pi-goals-notice", { content });
|
||||
expect(transform(content, { messageType: "user" })).toBe("");
|
||||
expect(transform(content, { messageType: "assistant" })).toBe(content);
|
||||
expect(transform("[pi-goals]\nA human quotation", { messageType: "user" })).toBe("[pi-goals]\nA human quotation");
|
||||
|
||||
const entry = { type: "custom", customType: "pi-goals-notice", data: { content } };
|
||||
const collapsed = render(entry, { expanded: false }, theme);
|
||||
for (const width of [24, 80]) {
|
||||
const lines = collapsed.render(width);
|
||||
expect(lines).toHaveLength(1);
|
||||
expect(visibleWidth(lines[0])).toBeLessThanOrEqual(width);
|
||||
expect(lines.join("\n")).not.toContain("Final evidence line");
|
||||
}
|
||||
const expanded = render(entry, { expanded: true }, theme);
|
||||
expect(expanded.render(80)).toEqual(new Markdown(content, 0, 0, getMarkdownTheme()).render(80));
|
||||
expect(expanded.render(80).join("\n")).toContain("Final evidence line");
|
||||
|
||||
display.restore({ sessionManager: { getBranch: () => [] } } as unknown as ExtensionContext);
|
||||
expect(transform(content, { messageType: "user" })).toBe(content);
|
||||
display.restore({ sessionManager: { getBranch: () => [entry] } } as unknown as ExtensionContext);
|
||||
expect(transform(content, { messageType: "user" })).toBe("");
|
||||
expect(entry.data.content).toBe(content);
|
||||
});
|
||||
@@ -9,7 +9,8 @@ import { describe, expect, it } from "vitest";
|
||||
import { foldPlan } from "../src/plan.js";
|
||||
|
||||
type RpcMessage = { type: string; id?: string; method?: string; [key: string]: unknown };
|
||||
type ModelRequest = { messages: Array<{ role: string; content: unknown }> };
|
||||
type ModelRequest = { messages: Array<{ role: string; content: string | Array<{ type: string; text?: string }> }> };
|
||||
const messageText = (content: ModelRequest["messages"][number]["content"]) => typeof content === "string" ? content : content.filter(part => part.type === "text").map(part => part.text).join("\n");
|
||||
|
||||
class RpcClient {
|
||||
readonly messages: RpcMessage[] = [];
|
||||
@@ -156,6 +157,13 @@ describe("RPC review flow", () => {
|
||||
expect(JSON.stringify(supervisor.messages)).toContain(JSON.stringify(foldPlan(approvedPlan)).slice(1, -1));
|
||||
expect(client.messages.filter(message => message.type === "tool_execution_start").map(message => message.toolName)).toEqual(["write"]);
|
||||
expect(client.messages.filter(message => message.type === "extension_error")).toEqual([]);
|
||||
const notices = client.messages.filter(message => message.type === "entry_appended" && (message.entry as { customType?: string })?.customType === "pi-goals-notice");
|
||||
expect(notices.length).toBeGreaterThanOrEqual(2);
|
||||
for (const notice of notices) {
|
||||
const content = (notice.entry as { data: { content: string } }).data.content;
|
||||
expect(client.messages.some(event => event.type === "message_end" && (event.message as any)?.role === "user" && (event.message as any)?.content[0]?.text === content)).toBe(true);
|
||||
expect(supervisor.messages.filter(message => message.role === "user" && messageText(message.content) === content)).toHaveLength(1);
|
||||
}
|
||||
console.log(`RPC ${choice}: visible automatic proposal; ${choice === "Edit" ? "editor saved exact plan without model call" : "discussion retained planning role without editor"}; Ready request used supervisor role; only write executed.`);
|
||||
} finally {
|
||||
pi.kill();
|
||||
|
||||
Reference in New Issue
Block a user