diff --git a/.github/workflows/publish-pi-later.yml b/.github/workflows/publish-pi-later.yml new file mode 100644 index 0000000..40cd96c --- /dev/null +++ b/.github/workflows/publish-pi-later.yml @@ -0,0 +1,32 @@ +name: Publish @derogab/pi-later package + +on: + push: + branches: + - master + paths: + - plugins/later/package.json + workflow_dispatch: + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + defaults: + run: + working-directory: plugins/later + + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: 24 + registry-url: https://registry.npmjs.org + package-manager-cache: false + - run: npm test + - run: npm pack --dry-run + - run: npm publish --provenance + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/plugins/README.md b/plugins/README.md index 108fe3b..2d56081 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -8,6 +8,7 @@ | [exit](./exit/) | Pi | Adds `/exit` as an alias for `/quit` | | [git](./git/) | Pi, Claude Code | Git workflow skills for commits and pull requests | | [inkypal](./inkypal/) | Claude Code | Notifies InkyPal when a task finish | +| [later](./later/) | Pi | Save prompts and run them later in the session | | [sounds](./sounds/) | Claude Code | OS-native sound alerts on events like task completion | ## Install @@ -24,6 +25,7 @@ pi install npm:@derogab/pi-clear pi install npm:@derogab/pi-dev pi install npm:@derogab/pi-exit pi install npm:@derogab/pi-git +pi install npm:@derogab/pi-later ``` ### Claude Code diff --git a/plugins/later/README.md b/plugins/later/README.md new file mode 100644 index 0000000..7506a37 --- /dev/null +++ b/plugins/later/README.md @@ -0,0 +1,28 @@ +# later + +A Pi plugin that adds `/later` to save prompts and run them later in the session. + +## Install + +### Pi + +Install the plugin from npm: + +```bash +pi install npm:@derogab/pi-later +``` + +## Usage + +- `/later `: save a prompt for later. +- `/later`: open the list of saved prompts. Pick one, then choose what to do with it: + - **Confirm**: send the prompt to the session and run it; the prompt is removed from the list once its turn actually starts, so a queued prompt that never runs is not lost. + - **Remove**: delete the prompt from the list without running it. + + Pressing Esc on either dialog leaves the list unchanged. + +After the session's first assistant response, saved prompts survive `/reload` and session resume. + +## Limitations + +- Pi creates a new session file only after its first assistant response. Prompts saved before that point remain in memory and are lost if Pi exits or replaces the session with `/new`, `/resume`, or `/fork` first. diff --git a/plugins/later/extensions/later.ts b/plugins/later/extensions/later.ts new file mode 100644 index 0000000..7b69733 --- /dev/null +++ b/plugins/later/extensions/later.ts @@ -0,0 +1,272 @@ +import { randomUUID } from "node:crypto"; +import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; + +const ENTRY_TYPE = "later"; +const MAX_LABEL_LENGTH = 80; +const ACTION_CONFIRM = "Confirm"; +const ACTION_REMOVE = "Remove"; +const MARKER_BOUNDARY = "\u2063"; +const VARIATION_SELECTOR_START = 0xfe00; +const DELIVERY_MARKER_PATTERN = /\u2063[\uFE00-\uFE0F]{32}\u2063/g; + +interface SavedPrompt { + text: string; +} + +interface PendingDelivery { + prompt: SavedPrompt; + idle: boolean; + marker: string; +} + +export default function (pi: ExtensionAPI) { + // Saved prompts, oldest first. Reconstructed from session entries. + let prompts: SavedPrompt[] = []; + let pendingDeliveries: PendingDelivery[] = []; + // Tie idle sends to their input lifecycle, including overlapping sends. + let awaitingIdleInput: PendingDelivery | undefined; + let latestIdleInput: PendingDelivery | undefined; + let pendingIdleInputs: PendingDelivery[] = []; + + const readPrompts = (ctx: ExtensionContext) => { + let restored: SavedPrompt[] = []; + for (const entry of ctx.sessionManager.getBranch()) { + if (entry.type === "custom" && entry.customType === ENTRY_TYPE) { + const data = entry.data as { prompts?: string[] } | undefined; + // Use distinct objects so duplicate prompt text still has stable selection identity. + restored = (data?.prompts ?? []).map((text) => ({ text })); + } + } + return restored; + }; + + const resetState = (ctx: ExtensionContext) => { + prompts = readPrompts(ctx); + pendingDeliveries = []; + awaitingIdleInput = undefined; + latestIdleInput = undefined; + pendingIdleInputs = []; + }; + + const persist = () => { + pi.appendEntry(ENTRY_TYPE, { prompts: prompts.map((prompt) => prompt.text) }); + }; + + const remove = (prompt: SavedPrompt) => { + const index = prompts.indexOf(prompt); + if (index === -1) return; + + prompts.splice(index, 1); + persist(); + }; + + const acknowledge = (delivery: PendingDelivery) => { + const index = pendingDeliveries.indexOf(delivery); + if (index === -1) return; + + pendingDeliveries.splice(index, 1); + pendingIdleInputs = pendingIdleInputs.filter((pending) => pending !== delivery); + if (latestIdleInput === delivery) latestIdleInput = undefined; + remove(delivery.prompt); + }; + + const createMarker = () => { + const selectors = [...randomUUID().replaceAll("-", "")] + .map((digit) => String.fromCharCode(VARIATION_SELECTOR_START + Number.parseInt(digit, 16))) + .join(""); + return `${MARKER_BOUNDARY}${selectors}${MARKER_BOUNDARY}`; + }; + + const createDelivery = (prompt: SavedPrompt, idle: boolean): PendingDelivery => { + const delivery = { prompt, idle, marker: createMarker() }; + pendingDeliveries.push(delivery); + return delivery; + }; + + const queueFollowUp = (prompt: SavedPrompt) => { + const delivery = createDelivery(prompt, false); + pi.sendUserMessage(`${prompt.text}${delivery.marker}`, { deliverAs: "followUp" }); + }; + + const clearDequeuedFollowUps = (ctx: ExtensionContext) => { + if (ctx.hasPendingMessages()) return; + pendingDeliveries = pendingDeliveries.filter((delivery) => delivery.idle); + }; + + const reconstructTreeState = (ctx: ExtensionContext) => { + const restored = readPrompts(ctx); + // Each tracked prompt claims a distinct restored copy so every pending + // delivery's acknowledgement still removes its own entry. Deliveries beyond + // the restored count keep a stale object and acknowledge as no-ops, and + // deliveries sharing one prompt object keep sharing it (one removal total). + const available = [...restored]; + const remapped = new Map(); + for (const delivery of pendingDeliveries) { + if (!prompts.includes(delivery.prompt)) continue; + + let replacement = remapped.get(delivery.prompt); + if (replacement === undefined) { + // Prefer the restored copy at the same same-text occurrence, so a + // delivery for a later duplicate can still share one removal with a + // re-selection of that same entry after reconstruction. + const sameText = (prompt: SavedPrompt) => prompt.text === delivery.prompt.text; + const ordinal = prompts.slice(0, prompts.indexOf(delivery.prompt)).filter(sameText).length; + const occurrence = restored.filter(sameText)[ordinal]; + let index = occurrence === undefined ? -1 : available.indexOf(occurrence); + if (index === -1) index = available.findIndex(sameText); + if (index === -1) continue; + replacement = available[index]; + available.splice(index, 1); + remapped.set(delivery.prompt, replacement); + } + delivery.prompt = replacement; + } + prompts = restored; + clearDequeuedFollowUps(ctx); + }; + + pi.on("session_start", async (_event, ctx) => resetState(ctx)); + pi.on("session_tree", async (_event, ctx) => reconstructTreeState(ctx)); + pi.on("agent_settled", async (_event, ctx) => clearDequeuedFollowUps(ctx)); + + const truncate = (text: string) => { + const singleLine = text.replace(/\s+/g, " ").trim(); + return singleLine.length > MAX_LABEL_LENGTH ? `${singleLine.slice(0, MAX_LABEL_LENGTH)}…` : singleLine; + }; + + const toLabel = (prompt: SavedPrompt, index: number) => `${index + 1}. ${truncate(prompt.text)}`; + + pi.on("input", async (event, ctx) => { + const delivery = awaitingIdleInput; + awaitingIdleInput = undefined; + const matchesIdleDelivery = + event.source === "extension" && + ctx.isIdle() && + delivery !== undefined && + event.text.includes(delivery.marker); + if (matchesIdleDelivery) { + pendingIdleInputs.push(delivery); + latestIdleInput = delivery; + return; + } + + latestIdleInput = undefined; + const matchesFollowUp = pendingDeliveries.some( + (pending) => !pending.idle && event.text.includes(pending.marker), + ); + if (!matchesFollowUp) clearDequeuedFollowUps(ctx); + }); + + pi.on("before_agent_start", async (event, ctx) => { + let delivery = pendingIdleInputs.find((pending) => event.prompt.includes(pending.marker)); + // If another input handler replaced the text, lifecycle order is safe only + // while this is the sole idle input waiting to start and the turn is not + // another tracked delivery's, e.g. an earlier-queued follow-up dequeued first. + if ( + delivery === undefined && + pendingIdleInputs.length === 1 && + latestIdleInput === pendingIdleInputs[0] && + !pendingDeliveries.some((pending) => event.prompt.includes(pending.marker)) + ) { + delivery = latestIdleInput; + } + if (delivery !== undefined) acknowledge(delivery); + else latestIdleInput = undefined; + clearDequeuedFollowUps(ctx); + }); + + pi.on("message_start", async (event) => { + const message = event.message; + if (message.role !== "user" || !("content" in message) || !Array.isArray(message.content)) return; + const content = message.content; + + const deliveries = pendingDeliveries.filter((delivery) => + content.some((part) => part.type === "text" && part.text.includes(delivery.marker)), + ); + + // Strip even an orphaned marker so internal tracking never reaches session + // history or the model after an extension reload or state reconstruction. + message.content = content.map((part) => + part.type === "text" ? { ...part, text: part.text.replace(DELIVERY_MARKER_PATTERN, "") } : part, + ); + for (const delivery of deliveries) acknowledge(delivery); + }); + + pi.registerCommand("later", { + description: "Save a prompt for later, or run a saved prompt", + handler: async (args, ctx) => { + clearDequeuedFollowUps(ctx); + const text = args.trim(); + + // /later : save it for later + if (text) { + prompts.push({ text }); + persist(); + ctx.ui.notify(`Saved for later (${prompts.length} pending)`, "info"); + return; + } + + // /later: pick a saved prompt to run + if (prompts.length === 0) { + ctx.ui.notify("Nothing saved for later. Use /later to save a prompt.", "info"); + return; + } + + if (!ctx.hasUI) { + ctx.ui.notify("/later requires interactive mode to pick a saved prompt", "error"); + return; + } + + const labels = prompts.map(toLabel); + const choice = await ctx.ui.select("Saved prompts", labels); + if (choice === undefined) return; + + const index = labels.indexOf(choice); + const prompt = prompts[index]; + if (prompt === undefined) return; + + const action = await ctx.ui.select(`Selected: ${truncate(prompt.text)}`, [ + ACTION_CONFIRM, + ACTION_REMOVE, + ]); + + if (action === ACTION_REMOVE) { + remove(prompt); + ctx.ui.notify(`Removed saved prompt (${prompts.length} pending)`, "info"); + return; + } + + if (action !== ACTION_CONFIRM) return; + + if (ctx.isIdle()) { + if (ctx.model === undefined) { + ctx.ui.notify("Could not run saved prompt, kept in list: no model selected", "error"); + return; + } + + const auth = await ctx.modelRegistry.getApiKeyAndHeaders(ctx.model); + if (!auth.ok) { + ctx.ui.notify(`Could not run saved prompt, kept in list: ${auth.error}`, "error"); + return; + } + + // Authentication can yield while another turn begins. Only use an idle + // send if the agent is still idle; otherwise use a race-safe follow-up. + if (ctx.isIdle()) { + // ExtensionAPI.sendUserMessage() is fire-and-forget. before_agent_start + // acknowledges an immediate turn, while followUp keeps the send safe if + // streaming starts during asynchronous input handlers. + const delivery = createDelivery(prompt, true); + awaitingIdleInput = delivery; + pi.sendUserMessage(`${prompt.text}${delivery.marker}`, { deliverAs: "followUp" }); + return; + } + } + + // Keep the prompt in the list until its marked follow-up user message + // actually starts, so an undelivered follow-up is not lost. + queueFollowUp(prompt); + ctx.ui.notify("Queued as follow-up", "info"); + }, + }); +} diff --git a/plugins/later/package.json b/plugins/later/package.json new file mode 100644 index 0000000..92ad9d7 --- /dev/null +++ b/plugins/later/package.json @@ -0,0 +1,32 @@ +{ + "name": "@derogab/pi-later", + "version": "0.1.0", + "description": "Save prompts and run them later in the session", + "type": "module", + "scripts": { + "test": "node --experimental-strip-types --test test/*.test.ts" + }, + "files": [ + "extensions", + "README.md" + ], + "keywords": [ + "pi-package" + ], + "repository": { + "type": "git", + "url": "https://github.com/derogab/agent-kit.git", + "directory": "plugins/later" + }, + "publishConfig": { + "access": "public" + }, + "pi": { + "extensions": [ + "./extensions/later.ts" + ] + }, + "peerDependencies": { + "@earendil-works/pi-coding-agent": "*" + } +} diff --git a/plugins/later/test/later.test.ts b/plugins/later/test/later.test.ts new file mode 100644 index 0000000..eb88a9e --- /dev/null +++ b/plugins/later/test/later.test.ts @@ -0,0 +1,481 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import later from "../extensions/later.ts"; + +interface CustomEntry { + type: "custom"; + customType: string; + data: { prompts: string[] }; + id: string; + parentId: string | null; + timestamp: string; +} + +const setup = ( + options: { + idle?: boolean; + authError?: string; + getAuth?: () => Promise<{ ok: true } | { ok: false; error: string }>; + initialEntries?: CustomEntry[]; + } = {}, +) => { + const handlers = new Map Promise>(); + const entries = (options.initialEntries ?? []).map((entry) => ({ + ...entry, + data: { prompts: [...entry.data.prompts] }, + })); + const notifications: Array<{ message: string; level: string }> = []; + const selections: Array<{ title: string; labels: string[] }> = []; + const sent: Array<{ prompt: string; options?: { deliverAs: "followUp" } }> = []; + const pendingMessages: string[] = []; + const queuedSelections: Array = []; + let idle = options.idle ?? true; + + const sessionManager = { + getBranch: () => entries, + }; + const ctx = { + hasUI: true, + hasPendingMessages: () => pendingMessages.length > 0, + isIdle: () => idle, + model: { provider: "test", id: "model" } as { provider: string; id: string } | undefined, + modelRegistry: { + getApiKeyAndHeaders: async () => { + if (options.getAuth !== undefined) return options.getAuth(); + return options.authError === undefined ? { ok: true } : { ok: false, error: options.authError }; + }, + }, + sessionManager, + ui: { + notify: (message: string, level: string) => notifications.push({ message, level }), + select: async (title: string, labels: string[]) => { + selections.push({ title, labels: [...labels] }); + return queuedSelections.length > 0 ? queuedSelections.shift() : labels[0]; + }, + }, + }; + let command: ((args: string, ctx: any) => Promise) | undefined; + const pi = { + appendEntry: (customType: string, data: { prompts: string[] }) => { + entries.push({ + type: "custom", + customType, + data, + id: String(entries.length + 1), + parentId: entries.at(-1)?.id ?? null, + timestamp: "2026-08-14T00:00:00.000Z", + }); + }, + on: (event: string, handler: (event: any, ctx: any) => Promise) => handlers.set(event, handler), + registerCommand: (_name: string, definition: { handler: (args: string, ctx: any) => Promise }) => { + command = definition.handler; + }, + sendUserMessage: (prompt: string, sendOptions?: { deliverAs: "followUp" }) => { + sent.push({ prompt, options: sendOptions }); + if (sendOptions?.deliverAs === "followUp" && !idle) pendingMessages.push(prompt); + }, + }; + + later(pi as any); + + return { + ctx, + dequeueFollowUps: () => pendingMessages.splice(0), + deliverFollowUp: (text: string) => { + const index = pendingMessages.indexOf(text); + if (index !== -1) pendingMessages.splice(index, 1); + }, + entries, + handlers, + notifications, + selections, + sent, + setIdle: (value: boolean) => { + idle = value; + }, + queueSelection: (value: string | undefined) => { + queuedSelections.push(value); + }, + run: async (args: string) => command!(args, ctx), + }; +}; + +const latestPrompts = (entries: CustomEntry[]) => entries.at(-1)?.data.prompts; + +const startUserMessage = async (fixture: ReturnType, text: string) => { + fixture.deliverFollowUp(text); + const event = { message: { role: "user", content: [{ type: "text", text }] } }; + await fixture.handlers.get("message_start")!(event, fixture.ctx); + return event; +}; + +const submitInput = async (fixture: ReturnType, text: string, source = "extension") => { + await fixture.handlers.get("input")!({ text, source }, fixture.ctx); +}; + +const assertHasInvisibleMarker = (sent: string, prompt: string) => { + assert.equal(sent.startsWith(prompt), true); + const marker = sent.slice(prompt.length); + assert.notEqual(marker, ""); + assert.doesNotMatch(marker, /[\x20-\x7e]/); +}; + +test("keeps an idle prompt when no model is selected", async () => { + const fixture = setup(); + fixture.ctx.model = undefined; + await fixture.run("keep me"); + await fixture.run(""); + + assert.deepEqual(fixture.sent, []); + assert.deepEqual(latestPrompts(fixture.entries), ["keep me"]); + assert.match(fixture.notifications.at(-1)!.message, /kept in list: no model selected/); +}); + +test("keeps an idle prompt when provider authentication is unavailable", async () => { + const fixture = setup({ authError: "No API key" }); + await fixture.run("keep me"); + await fixture.run(""); + + assert.deepEqual(fixture.sent, []); + assert.deepEqual(latestPrompts(fixture.entries), ["keep me"]); + assert.match(fixture.notifications.at(-1)!.message, /kept in list: No API key/); +}); + +test("restores prompts from session state without mutating saved entries", async () => { + const source = setup(); + await source.run("first"); + await source.run("second"); + const persisted = source.entries.map((entry) => [...entry.data.prompts]); + const fixture = setup({ initialEntries: source.entries }); + + await fixture.handlers.get("session_start")!({}, fixture.ctx); + await fixture.handlers.get("session_tree")!({}, fixture.ctx); + fixture.queueSelection("1. first"); + fixture.queueSelection("Remove"); + await fixture.run(""); + + assert.deepEqual(latestPrompts(fixture.entries), ["second"]); + assert.deepEqual( + fixture.entries.slice(0, persisted.length).map((entry) => entry.data.prompts), + persisted, + ); + assert.deepEqual(source.entries.map((entry) => entry.data.prompts), persisted); +}); + +test("truncates labels for long saved prompts", async () => { + const fixture = setup(); + const prompt = "x".repeat(81); + await fixture.run(prompt); + fixture.queueSelection(undefined); + await fixture.run(""); + + assert.deepEqual(fixture.selections, [{ title: "Saved prompts", labels: [`1. ${"x".repeat(80)}…`] }]); +}); + +test("removes an idle prompt only when Pi accepts the turn", async () => { + const fixture = setup(); + await fixture.run("run me"); + await fixture.run(""); + + assert.equal(fixture.sent.length, 1); + assertHasInvisibleMarker(fixture.sent[0].prompt, "run me"); + assert.deepEqual(fixture.sent[0].options, { deliverAs: "followUp" }); + assert.deepEqual(latestPrompts(fixture.entries), ["run me"]); + + await submitInput(fixture, fixture.sent[0].prompt); + await fixture.handlers.get("before_agent_start")!({ prompt: fixture.sent[0].prompt }, fixture.ctx); + const event = await startUserMessage(fixture, fixture.sent[0].prompt); + assert.equal(event.message.content[0].text, "run me"); + assert.deepEqual(latestPrompts(fixture.entries), []); +}); + +test("preserves a saved prompt prefix for input handlers", async () => { + const fixture = setup({ idle: false }); + await fixture.run("?quick run me"); + await fixture.run(""); + + assertHasInvisibleMarker(fixture.sent[0].prompt, "?quick run me"); +}); + +test("acknowledges an idle prompt transformed by an input handler", async () => { + const fixture = setup(); + await fixture.run("original"); + await fixture.run(""); + + await submitInput(fixture, fixture.sent[0].prompt); + await fixture.handlers.get("before_agent_start")!({ prompt: "transformed" }, fixture.ctx); + assert.deepEqual(latestPrompts(fixture.entries), []); +}); + +test("acknowledges an idle delivery while a follow-up is pending", async () => { + const fixture = setup(); + await fixture.run("idle prompt"); + await fixture.run(""); + await submitInput(fixture, fixture.sent[0].prompt); + fixture.setIdle(false); + await fixture.run("queued prompt"); + fixture.queueSelection("2. queued prompt"); + await fixture.run(""); + await submitInput(fixture, fixture.sent.at(-1)!.prompt); + + await fixture.handlers.get("before_agent_start")!({ prompt: fixture.sent[0].prompt }, fixture.ctx); + assert.deepEqual(latestPrompts(fixture.entries), ["queued prompt"]); +}); + +test("acknowledges overlapping idle deliveries independently", async () => { + const fixture = setup(); + await fixture.run("first"); + await fixture.run("second"); + fixture.queueSelection("1. first"); + await fixture.run(""); + await submitInput(fixture, fixture.sent[0].prompt); + fixture.queueSelection("2. second"); + await fixture.run(""); + await submitInput(fixture, fixture.sent[1].prompt); + + await fixture.handlers.get("before_agent_start")!({ prompt: fixture.sent[0].prompt }, fixture.ctx); + await fixture.handlers.get("before_agent_start")!({ prompt: fixture.sent[1].prompt }, fixture.ctx); + assert.deepEqual(latestPrompts(fixture.entries), []); +}); + +test("keeps an idle delivery when an earlier-queued follow-up's turn starts first", async () => { + const fixture = setup({ idle: false }); + await fixture.run("queued"); + await fixture.run("idle"); + fixture.queueSelection("1. queued"); + await fixture.run(""); + await submitInput(fixture, fixture.sent[0].prompt); + + // The agent settles with the follow-up still queued; an idle send races in. + fixture.setIdle(true); + fixture.queueSelection("2. idle"); + await fixture.run(""); + await submitInput(fixture, fixture.sent[1].prompt); + + // The earlier-queued follow-up's turn starts before the idle delivery's. + await fixture.handlers.get("before_agent_start")!({ prompt: fixture.sent[0].prompt }, fixture.ctx); + assert.deepEqual(latestPrompts(fixture.entries), ["queued", "idle"]); + + await startUserMessage(fixture, fixture.sent[0].prompt); + assert.deepEqual(latestPrompts(fixture.entries), ["idle"]); + + await fixture.handlers.get("before_agent_start")!({ prompt: fixture.sent[1].prompt }, fixture.ctx); + assert.deepEqual(latestPrompts(fixture.entries), []); +}); + +test("keeps an idle delivery when unrelated input starts first", async () => { + const fixture = setup(); + await fixture.run("idle prompt"); + await fixture.run(""); + await submitInput(fixture, fixture.sent[0].prompt); + await submitInput(fixture, "unrelated", "interactive"); + + await fixture.handlers.get("before_agent_start")!({ prompt: "unrelated" }, fixture.ctx); + assert.deepEqual(latestPrompts(fixture.entries), ["idle prompt"]); +}); + +test("queues a prompt when the agent starts during authentication", async () => { + let authRequested!: () => void; + const requested = new Promise((resolve) => { + authRequested = resolve; + }); + let resolveAuth!: (result: { ok: true }) => void; + const auth = new Promise<{ ok: true }>((resolve) => { + resolveAuth = resolve; + }); + const fixture = setup({ + getAuth: async () => { + authRequested(); + return auth; + }, + }); + await fixture.run("race-safe prompt"); + const confirmation = fixture.run(""); + await requested; + fixture.setIdle(false); + resolveAuth({ ok: true }); + await confirmation; + + assert.equal(fixture.sent.length, 1); + assertHasInvisibleMarker(fixture.sent[0].prompt, "race-safe prompt"); + assert.deepEqual(fixture.sent[0].options, { deliverAs: "followUp" }); + await fixture.handlers.get("before_agent_start")!({ prompt: "unrelated" }, fixture.ctx); + assert.deepEqual(latestPrompts(fixture.entries), ["race-safe prompt"]); +}); + +test("removes the selected duplicate follow-up when its turn starts", async () => { + const fixture = setup({ idle: false }); + await fixture.run("A"); + await fixture.run("B"); + await fixture.run("A"); + fixture.queueSelection("3. A"); + await fixture.run(""); + + assert.equal(fixture.sent.length, 1); + assertHasInvisibleMarker(fixture.sent[0].prompt, "A"); + assert.deepEqual(fixture.sent[0].options, { deliverAs: "followUp" }); + // Queueing the follow-up must not remove the prompt before delivery. + assert.deepEqual(latestPrompts(fixture.entries), ["A", "B", "A"]); + + await startUserMessage(fixture, fixture.sent[0].prompt); + assert.deepEqual(latestPrompts(fixture.entries), ["A", "B"]); +}); + +test("removes the selected duplicate after an idle turn is accepted", async () => { + const fixture = setup(); + await fixture.run("A"); + await fixture.run("B"); + await fixture.run("A"); + fixture.queueSelection("3. A"); + await fixture.run(""); + await submitInput(fixture, fixture.sent[0].prompt); + await fixture.handlers.get("before_agent_start")!({ prompt: fixture.sent[0].prompt }, fixture.ctx); + + assert.deepEqual(latestPrompts(fixture.entries), ["A", "B"]); +}); + +test("keeps a queued follow-up when a user types the same text", async () => { + const fixture = setup({ idle: false }); + await fixture.run("B"); + await fixture.run(""); + + assert.equal(fixture.sent.length, 1); + assert.deepEqual(fixture.sent[0].options, { deliverAs: "followUp" }); + assert.deepEqual(latestPrompts(fixture.entries), ["B"]); + + // A user-typed message can overtake the queued follow-up with identical text. + await fixture.handlers.get("before_agent_start")!({ prompt: "B" }, fixture.ctx); + await startUserMessage(fixture, "B"); + assert.deepEqual(latestPrompts(fixture.entries), ["B"]); + + const event = await startUserMessage(fixture, fixture.sent[0].prompt); + assert.equal(event.message.content[0].text, "B"); + assert.deepEqual(latestPrompts(fixture.entries), []); +}); + +test("keeps and can rerun a follow-up removed from Pi's queue", async () => { + const fixture = setup({ idle: false }); + await fixture.run("run later"); + await fixture.run(""); + fixture.dequeueFollowUps(); + await fixture.handlers.get("agent_settled")?.({}, fixture.ctx); + + fixture.setIdle(true); + await fixture.run(""); + await submitInput(fixture, fixture.sent[1].prompt); + await fixture.handlers.get("before_agent_start")!({ prompt: fixture.sent[1].prompt }, fixture.ctx); + + assert.deepEqual(latestPrompts(fixture.entries), []); +}); + +test("keeps and can rerun a dequeued follow-up after its text is edited", async () => { + const fixture = setup({ idle: false }); + await fixture.run("run later"); + await fixture.run(""); + fixture.dequeueFollowUps(); + await submitInput(fixture, "edited prompt", "interactive"); + await startUserMessage(fixture, "edited prompt"); + assert.deepEqual(latestPrompts(fixture.entries), ["run later"]); + + fixture.setIdle(true); + await fixture.run(""); + await submitInput(fixture, fixture.sent[1].prompt); + await fixture.handlers.get("before_agent_start")!({ prompt: fixture.sent[1].prompt }, fixture.ctx); + + assert.deepEqual(latestPrompts(fixture.entries), []); +}); + +test("keeps follow-up delivery tracking across session tree navigation", async () => { + const fixture = setup({ idle: false }); + await fixture.run("queued prompt"); + await fixture.run(""); + const queued = fixture.sent[0].prompt; + + await fixture.handlers.get("session_tree")!({}, fixture.ctx); + const event = await startUserMessage(fixture, queued); + + assert.equal(event.message.content[0].text, "queued prompt"); + assert.deepEqual(latestPrompts(fixture.entries), []); +}); + +test("acknowledges a delivered duplicate after navigating to a branch with fewer copies", async () => { + const fixture = setup({ idle: false }); + await fixture.run("A"); + await fixture.run("A"); + fixture.queueSelection("2. A"); + await fixture.run(""); + const queued = fixture.sent[0].prompt; + assert.deepEqual(latestPrompts(fixture.entries), ["A", "A"]); + + // Navigate to the branch point where only one copy was saved. + fixture.entries.pop(); + await fixture.handlers.get("session_tree")!({}, fixture.ctx); + + await startUserMessage(fixture, queued); + assert.deepEqual(latestPrompts(fixture.entries), []); +}); + +test("acknowledges each pending duplicate distinctly after navigating to fewer copies", async () => { + const fixture = setup({ idle: false }); + await fixture.run("A"); + await fixture.run("A"); + await fixture.run("A"); + fixture.queueSelection("2. A"); + await fixture.run(""); + fixture.queueSelection("3. A"); + await fixture.run(""); + assert.deepEqual(latestPrompts(fixture.entries), ["A", "A", "A"]); + + // Navigate to the branch point where only two copies were saved. + fixture.entries.pop(); + await fixture.handlers.get("session_tree")!({}, fixture.ctx); + + await startUserMessage(fixture, fixture.sent[0].prompt); + assert.deepEqual(latestPrompts(fixture.entries), ["A"]); + await startUserMessage(fixture, fixture.sent[1].prompt); + assert.deepEqual(latestPrompts(fixture.entries), []); +}); + +test("keeps a duplicate's occurrence identity across session tree navigation", async () => { + const fixture = setup({ idle: false }); + await fixture.run("A"); + await fixture.run("A"); + fixture.queueSelection("2. A"); + await fixture.run(""); + assert.deepEqual(latestPrompts(fixture.entries), ["A", "A"]); + + await fixture.handlers.get("session_tree")!({}, fixture.ctx); + + // Re-selecting the same second copy must share its pending delivery's + // prompt object, so both deliveries remove one saved entry total. + fixture.queueSelection("2. A"); + await fixture.run(""); + + await startUserMessage(fixture, fixture.sent[0].prompt); + await startUserMessage(fixture, fixture.sent[1].prompt); + assert.deepEqual(latestPrompts(fixture.entries), ["A"]); +}); + +test("removes a prompt without running it when Remove is chosen", async () => { + const fixture = setup({ idle: false }); + await fixture.run("A"); + await fixture.run("B"); + fixture.queueSelection("2. B"); + fixture.queueSelection("Remove"); + await fixture.run(""); + + assert.deepEqual(fixture.sent, []); + assert.deepEqual(latestPrompts(fixture.entries), ["A"]); + assert.match(fixture.notifications.at(-1)!.message, /Removed saved prompt \(1 pending\)/); +}); + +test("keeps a prompt when the action choice is cancelled", async () => { + const fixture = setup(); + await fixture.run("keep me"); + fixture.queueSelection("1. keep me"); + fixture.queueSelection(undefined); + await fixture.run(""); + + assert.deepEqual(fixture.sent, []); + assert.deepEqual(latestPrompts(fixture.entries), ["keep me"]); +});