diff --git a/README.md b/README.md index a1204d0..fdb59e0 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ # @eko24ive/pi-ask [![npm downloads](https://badgen.net/npm/dm/@eko24ive/pi-ask)](https://www.npmjs.com/package/@eko24ive/pi-ask) -[![last commit](https://badgen.net/github/last-commit/eko24ive/pi-ask?v=bbb7017)](https://github.com/eko24ive/pi-ask/commits/main) +[![last commit](https://badgen.net/github/last-commit/eko24ive/pi-ask?v=0145055)](https://github.com/eko24ive/pi-ask/commits/main) [![stars](https://badgen.net/github/stars/eko24ive/pi-ask)](https://github.com/eko24ive/pi-ask/stargazers) > [!IMPORTANT] @@ -60,6 +60,7 @@ Once installed, this package gives the agent a native way to ask for clarificati - `/answer` extracts questions from the latest assistant message into an ask flow - `/answer:again` reopens the latest `/answer` form on the current branch - `/ask:replay` replays the latest real `ask_user` form on the current branch +- 🛟 Automatic recovery of an unanswered `ask_user` form after startup, resume, or fork - 🗣️ You can talk to your agent to configure pi-ask; it will read the bundled configuration guide and tailor the config for you ## Feature walkthrough @@ -210,6 +211,12 @@ Replay commands are branch-aware. They read persisted entries from the current p Cancellation is local to the UI: closing a replayed form does not start a new agent turn. Submitted answers are sent back as a normal user follow-up message. +### Interrupted ask forms + +If Pi stops while an `ask_user` form is open, the tool call remains without a result. Starting, resuming, or forking that session reopens the newest unanswered form once. Submitting sends the result as a user message because the original tool execution no longer exists. Cancelling dismisses the automatic recovery. Either outcome prevents another automatic reopen, while `/ask:replay` remains available. + +New sessions and extension reloads do not trigger recovery. + Kudos to [@k0valik](https://github.com/k0valik) for the `/answer` idea. You can also talk to pi to configure this extension. When asked to customize pi-ask settings, keymaps, notifications, or extraction behavior, the agent is instructed to read the bundled `docs/configuration.md` guide first and then edit the config file accordingly. diff --git a/docs/architecture.md b/docs/architecture.md index b30905e..4859d78 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -18,6 +18,8 @@ The codebase is split so the implementation reads through file boundaries and na - `src/answer-commands.ts` — `/answer`, `/answer:again`, and `/ask:replay` command wiring - `src/answer-extraction.ts` — configured extraction model selection, synthetic `ask_user` tool-call extraction, and tolerant text fallback - `src/ask-payload-store.ts` — branch-aware persisted ask payload lookup +- `src/pending-ask.ts` — active-branch unresolved tool-call detection and recovery dismissal persistence +- `src/resume-pending-ask.ts` — detached lifecycle wiring and recovered answer delivery - `src/notifications.ts` — best-effort ask notification payload rendering and channel execution - `src/remote-ask.ts` — package-prefixed local event contract, active-flow registry, and explicit remote submission validation - `src/schema.ts` — TypeBox schema @@ -78,6 +80,9 @@ The codebase is split so the implementation reads through file boundaries and na - persisted ask settings are migrated to the current schema version in memory, validated, and normalized before use without rewriting the config file on load - config schema migrations preserve user-provided values and add new defaults only when fields are absent - replay payload lookup scans only the current session branch and revalidates payloads before use +- interrupted-ask recovery selects only the newest unresolved `ask_user` call on the active branch, prefers its valid persisted payload, and validates original arguments as fallback +- recovered forms open only for TUI `startup`, `resume`, and `fork` events; the flow stays detached from the lifecycle handler +- recovered submit and cancel both persist dismissal before the same call can reopen; manual `/ask:replay` ignores that recovery marker - invalid persisted keymaps fall back to default keymaps for the current session without discarding valid behaviour, notification, or answer settings - invalid notification channels are skipped and fall back to the default bell channel if none are valid - ask settings behaviour and notification enabled changes attempt to persist immediately from the settings list; save failures revert the change and show an error; config reset is guarded by a short double-press confirmation diff --git a/docs/contract.md b/docs/contract.md index 2b45f00..23c3cb1 100644 --- a/docs/contract.md +++ b/docs/contract.md @@ -213,6 +213,7 @@ This document defines the stable external behavior. It does not explain internal - `/answer` extraction may use an internal `freeform: true` option for open-ended questions with no explicit choices; these render as user-input-only questions with the label `Type your answer:`, no numbered option row, and no selection caret; this marker is not part of the public `ask_user` tool contract - `/answer:again` command to replay the latest `/answer`-extracted form on the current branch - `/ask:replay` command to replay the latest real `ask_user` form on the current branch +- automatic recovery of the newest unresolved `ask_user` form on startup, resume, or fork - ask settings list with binary behaviour/notification toggles and a guarded reset-to-defaults action - `?` in the ask flow and `/ask-settings` in pi open the same lightweight ask settings overlay - settings attempt to persist immediately when changed: `Auto-submit when answered without notes`, `Confirm dismiss when dirty`, `Double-press review shortcuts`, `Notifications`, and `Show footer hints`; `Present single-select as multi-select` persists immediately when saving succeeds but applies only to new/replayed ask flows; save failures revert the setting and show a manual-edit message; resetting config to defaults requires pressing the reset action twice within a short confirmation window @@ -295,6 +296,16 @@ See [`remote-events.md`](remote-events.md) for payload shapes, examples, and a l - submitted or elaborated command-flow results are sent back with user-message semantics - replay commands scan only `ctx.sessionManager.getBranch()`, ignore sibling/future branch payloads, and revalidate stored payloads before opening the UI +## Interrupted ask resume + +- on `session_start` with reason `startup`, `resume`, or `fork`, pi-ask finds the newest `ask_user` tool call on the active branch that has neither a tool result nor an `ask:pending-dismissed` entry +- recovery does not run for `new`, `reload`, or non-TUI sessions +- the matching valid `ask:payload` supplies the form; if it is missing or invalid, pi-ask validates and uses the original tool call arguments instead +- the recovery flow is detached from `session_start`, so an open form does not block other lifecycle handlers +- because the interrupted `execute` promise no longer exists, submit sends the result with the same user-message semantics as replay commands +- submit and cancel both append `ask:pending-dismissed`, which prevents another automatic reopen; `/ask:replay` still works +- recovered flows emit remote lifecycle events with source `ask:resume` + The fallback message includes normalized pending questions and options so the caller can re-ask them manually. `details.questions` still contains normalized question metadata, while `details.answers` stays empty until a user responds. ## Skill alignment (advisory) diff --git a/docs/remote-events.md b/docs/remote-events.md index 34654a7..30e361d 100644 --- a/docs/remote-events.md +++ b/docs/remote-events.md @@ -25,14 +25,14 @@ type PiAskStartedEvent = { version: 1; flowId: string; toolCallId?: string; - source: "tool" | "answer" | "answer:again" | "ask:replay"; + source: "tool" | "answer" | "answer:again" | "ask:replay" | "ask:resume"; title?: string; questions: AskQuestion[]; createdAt: number; }; ``` -Use `flowId` for submit/correlation. Use `questions[].id` and `questions[].options[].value` for answers. +Use `flowId` for submit/correlation. Use `questions[].id` and `questions[].options[].value` for answers. `ask:resume` identifies a form recovered from an interrupted tool call. ## Submit an answer @@ -111,7 +111,7 @@ type PiAskCompletedEvent = { version: 1; flowId: string; toolCallId?: string; - source: "tool" | "answer" | "answer:again" | "ask:replay"; + source: "tool" | "answer" | "answer:again" | "ask:replay" | "ask:resume"; result: AskResult; completedAt: number; }; diff --git a/src/ask-payload-store.ts b/src/ask-payload-store.ts index b68a3db..07a291f 100644 --- a/src/ask-payload-store.ts +++ b/src/ask-payload-store.ts @@ -50,6 +50,26 @@ export function findLatestPayloadInCurrentBranch( return { invalidMatchFound }; } +export function findPayloadForSourceEntry( + ctx: Pick, + sourceEntryId: string, + source: AskPayloadSource +): AskPayloadEntryData | undefined { + for (const entry of [...ctx.sessionManager.getBranch()].reverse()) { + if (!isAskPayloadEntry(entry)) { + continue; + } + const data = entry.data; + if (data?.source !== source || data.sourceEntryId !== sourceEntryId) { + continue; + } + if (isValidAskPayloadData(data)) { + return data; + } + } + return; +} + function isAskPayloadEntry(entry: unknown): entry is { customType: string; data?: Partial; diff --git a/src/index.ts b/src/index.ts index cab322d..ea16c1e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ import { registerAskSettingsCommand } from "./ask-settings-command.ts"; import { registerAskTool } from "./ask-tool.ts"; import { resetAskConfigStore } from "./config/store.ts"; import { createRemoteAskRuntime } from "./remote-ask.ts"; +import { registerPendingAskResume } from "./resume-pending-ask.ts"; const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const CONFIGURATION_DOC_PATH = resolve( @@ -27,4 +28,5 @@ export default function askExtension(pi: ExtensionAPI) { registerAskTool(pi, remoteAsk); registerAskSettingsCommand(pi); registerAnswerCommands(pi, remoteAsk); + registerPendingAskResume(pi, remoteAsk); } diff --git a/src/pending-ask.ts b/src/pending-ask.ts new file mode 100644 index 0000000..8babf5a --- /dev/null +++ b/src/pending-ask.ts @@ -0,0 +1,125 @@ +import type { ToolCall } from "@earendil-works/pi-ai"; +import type { + ExtensionAPI, + ExtensionContext, + SessionEntry, +} from "@earendil-works/pi-coding-agent"; +import { findPayloadForSourceEntry } from "./ask-payload-store.ts"; +import { validateParams } from "./ask-tool-helpers.ts"; +import type { AskParams } from "./types.ts"; + +export const ASK_PENDING_DISMISSED_ENTRY_TYPE = "ask:pending-dismissed"; +const ASK_TOOL_NAME = "ask_user"; + +export interface PendingAskToolCall { + params: AskParams; + toolCallId: string; +} + +type AskToolCall = ToolCall; + +export function appendPendingAskDismissal( + pi: Pick, + toolCallId: string +): void { + pi.appendEntry(ASK_PENDING_DISMISSED_ENTRY_TYPE, { toolCallId }); +} + +export function findPendingAskToolCall( + ctx: Pick +): PendingAskToolCall | undefined { + const branch = ctx.sessionManager.getBranch(); + const resolvedToolCallIds = collectResolvedToolCallIds(branch); + + for (let entryIndex = branch.length - 1; entryIndex >= 0; entryIndex--) { + const toolCall = findUnresolvedAskToolCall( + branch[entryIndex], + resolvedToolCallIds + ); + if (!toolCall) { + continue; + } + + const params = resolvePendingAskParams(ctx, toolCall); + if (params) { + return { params, toolCallId: toolCall.id }; + } + } + return; +} + +function collectResolvedToolCallIds( + branch: readonly SessionEntry[] +): Set { + const resolved = new Set(); + for (const entry of branch) { + const dismissedToolCallId = getDismissedToolCallId(entry); + if (dismissedToolCallId) { + resolved.add(dismissedToolCallId); + continue; + } + if (entry.type === "message" && entry.message.role === "toolResult") { + resolved.add(entry.message.toolCallId); + } + } + return resolved; +} + +function findUnresolvedAskToolCall( + entry: SessionEntry, + resolvedToolCallIds: ReadonlySet +): AskToolCall | undefined { + if ( + entry.type !== "message" || + entry.message.role !== "assistant" || + entry.message.stopReason !== "toolUse" + ) { + return; + } + + for ( + let partIndex = entry.message.content.length - 1; + partIndex >= 0; + partIndex-- + ) { + const part = entry.message.content[partIndex]; + if ( + part.type === "toolCall" && + part.name === ASK_TOOL_NAME && + !resolvedToolCallIds.has(part.id) + ) { + return part; + } + } + return; +} + +function resolvePendingAskParams( + ctx: Pick, + toolCall: AskToolCall +): AskParams | undefined { + const persistedPayload = findPayloadForSourceEntry(ctx, toolCall.id, "tool"); + if (persistedPayload) { + return persistedPayload.params; + } + + const argumentsFallback = toolCall.arguments as AskParams | undefined; + if (argumentsFallback && validateParams(argumentsFallback).ok) { + return argumentsFallback; + } + return; +} + +function getDismissedToolCallId(entry: SessionEntry): string | undefined { + if ( + entry.type !== "custom" || + entry.customType !== ASK_PENDING_DISMISSED_ENTRY_TYPE || + !entry.data || + typeof entry.data !== "object" + ) { + return; + } + + const toolCallId = (entry.data as { toolCallId?: unknown }).toolCallId; + return typeof toolCallId === "string" ? toolCallId : undefined; +} diff --git a/src/remote-ask.ts b/src/remote-ask.ts index 4c8692b..84643c5 100644 --- a/src/remote-ask.ts +++ b/src/remote-ask.ts @@ -12,7 +12,12 @@ export const PI_ASK_COMPLETED_EVENT = "@eko24ive/pi-ask:completed"; export const PI_ASK_SUBMIT_EVENT = "@eko24ive/pi-ask:submit"; export const PI_ASK_SUBMIT_RESULT_EVENT = "@eko24ive/pi-ask:submit-result"; -export type RemoteAskSource = "tool" | "answer" | "answer:again" | "ask:replay"; +export type RemoteAskSource = + | "tool" + | "answer" + | "answer:again" + | "ask:replay" + | "ask:resume"; export interface RemoteAskAnswer { customText?: string; diff --git a/src/resume-pending-ask.ts b/src/resume-pending-ask.ts new file mode 100644 index 0000000..097205f --- /dev/null +++ b/src/resume-pending-ask.ts @@ -0,0 +1,95 @@ +import type { + ExtensionAPI, + ExtensionContext, + SessionStartEvent, +} from "@earendil-works/pi-coding-agent"; +import { successfulResponse } from "./ask-tool-helpers.ts"; +import { + appendPendingAskDismissal, + findPendingAskToolCall, + type PendingAskToolCall, +} from "./pending-ask.ts"; +import type { RemoteAskRuntime } from "./remote-ask.ts"; +import { runAskFlow } from "./ui/controller.ts"; + +const REOPEN_REASONS: ReadonlySet = new Set([ + "startup", + "resume", + "fork", +]); +const DISMISS_NOTICE = + "Unanswered ask_user form dismissed; use /ask:replay to reopen it."; + +export function registerPendingAskResume( + pi: ExtensionAPI, + remoteAsk: RemoteAskRuntime +): void { + let reopening = false; + + pi.on("session_start", (event, ctx) => { + if (reopening || ctx.mode !== "tui" || !REOPEN_REASONS.has(event.reason)) { + return; + } + + const pendingAsk = findPendingAskToolCall(ctx); + if (!pendingAsk) { + return; + } + + reopening = true; + queueMicrotask(() => { + reopenPendingAsk(pi, ctx, pendingAsk, remoteAsk) + .catch((error) => { + ctx.ui.notify( + `Could not reopen unanswered ask_user form: ${formatError(error)}`, + "error" + ); + }) + .finally(() => { + reopening = false; + }); + }); + }); +} + +async function reopenPendingAsk( + pi: Pick, + ctx: ExtensionContext, + pendingAsk: PendingAskToolCall, + remoteAsk: RemoteAskRuntime +): Promise { + ctx.ui.notify( + `Reopening unanswered ask_user form: ${pendingAsk.params.questions.length} question(s).`, + "info" + ); + ctx.ui.setWorkingVisible(false); + + let result: Awaited>; + try { + result = await runAskFlow(ctx, pendingAsk.params, { + remote: { + runtime: remoteAsk, + source: "ask:resume", + toolCallId: pendingAsk.toolCallId, + }, + }); + } finally { + ctx.ui.setWorkingVisible(true); + } + + appendPendingAskDismissal(pi, pendingAsk.toolCallId); + if (result.cancelled) { + ctx.ui.notify(DISMISS_NOTICE, "info"); + return; + } + + const text = successfulResponse(result).content[0].text; + pi.sendUserMessage( + text, + ctx.isIdle() ? undefined : { deliverAs: "followUp" } + ); +} + +function formatError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/tests/pending-ask.test.ts b/tests/pending-ask.test.ts new file mode 100644 index 0000000..ba095e2 --- /dev/null +++ b/tests/pending-ask.test.ts @@ -0,0 +1,482 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { findLatestPayloadInCurrentBranch } from "../src/ask-payload-store.ts"; +import { DEFAULT_ASK_CONFIG } from "../src/config/defaults.ts"; +import { getAskConfigStore } from "../src/config/store.ts"; +import { + ASK_PENDING_DISMISSED_ENTRY_TYPE, + findPendingAskToolCall, +} from "../src/pending-ask.ts"; +import { + createRemoteAskRuntime, + PI_ASK_COMPLETED_EVENT, + PI_ASK_STARTED_EVENT, + PI_ASK_SUBMIT_EVENT, + type RemoteAskCompletedEvent, + type RemoteAskStartedEvent, +} from "../src/remote-ask.ts"; +import { registerPendingAskResume } from "../src/resume-pending-ask.ts"; +import type { AskParams } from "../src/types.ts"; + +const CANVAS_RE = /Canvas/; + +const params: AskParams = { + title: "Choose engine", + questions: [ + { + id: "engine", + prompt: "Chart engine?", + options: [ + { value: "canvas", label: "Canvas" }, + { value: "svg", label: "SVG" }, + ], + }, + ], +}; + +class TestEventBus { + readonly events: Array<{ channel: string; data: unknown }> = []; + private readonly handlers = new Map void>>(); + + emit(channel: string, data: unknown): void { + this.events.push({ channel, data }); + for (const handler of this.handlers.get(channel) ?? []) { + handler(data); + } + } + + on(channel: string, handler: (data: unknown) => void): () => void { + const handlers = this.handlers.get(channel) ?? []; + handlers.push(handler); + this.handlers.set(channel, handlers); + return () => { + this.handlers.set( + channel, + (this.handlers.get(channel) ?? []).filter( + (candidate) => candidate !== handler + ) + ); + }; + } +} + +function askToolCall(id: string, argumentsValue: unknown = params) { + return assistantMessage([ + { type: "toolCall", id, name: "ask_user", arguments: argumentsValue }, + ]); +} + +function assistantMessage(content: unknown[], stopReason = "toolUse") { + return { + type: "message", + message: { role: "assistant", content, stopReason }, + }; +} + +function toolResult(toolCallId: string) { + return { + type: "message", + message: { role: "toolResult", toolCallId, toolName: "ask_user" }, + }; +} + +function storedPayload( + sourceEntryId: string, + storedParams: AskParams = params, + version = 1 +) { + return { + type: "custom", + customType: "ask:payload", + data: { + version, + source: "tool", + params: storedParams, + sourceEntryId, + timestamp: 1, + }, + }; +} + +function dismissed(toolCallId: string) { + return { + type: "custom", + customType: ASK_PENDING_DISMISSED_ENTRY_TYPE, + data: { toolCallId }, + }; +} + +function scannerContext(branch: unknown[]) { + return { sessionManager: { getBranch: () => branch } } as never; +} + +test("pending ask scan selects the newest unresolved ask_user call", () => { + const pending = findPendingAskToolCall( + scannerContext([ + askToolCall("call-1"), + storedPayload("call-1"), + askToolCall("call-2"), + storedPayload("call-2"), + toolResult("call-2"), + ]) + ); + + assert.deepEqual(pending, { params, toolCallId: "call-1" }); +}); + +test("pending ask scan uses the latest unresolved call within one assistant message", () => { + const pending = findPendingAskToolCall( + scannerContext([ + assistantMessage([ + { type: "toolCall", id: "call-1", name: "ask_user", arguments: params }, + { type: "toolCall", id: "other", name: "read", arguments: {} }, + { type: "toolCall", id: "call-2", name: "ask_user", arguments: params }, + ]), + ]) + ); + + assert.equal(pending?.toolCallId, "call-2"); +}); + +test("pending ask scan ignores tool-shaped content from failed assistant responses", () => { + assert.equal( + findPendingAskToolCall( + scannerContext([ + assistantMessage( + [ + { + type: "toolCall", + id: "failed-call", + name: "ask_user", + arguments: params, + }, + ], + "error" + ), + ]) + ), + undefined + ); +}); + +test("pending ask scan prefers its valid persisted payload", () => { + const persistedParams = { ...params, title: "Persisted title" }; + const pending = findPendingAskToolCall( + scannerContext([ + askToolCall("call-1", { ...params, title: "Recorded title" }), + storedPayload("call-1", persistedParams), + ]) + ); + + assert.equal(pending?.params, persistedParams); +}); + +test("pending ask scan validates recorded arguments as payload fallback", () => { + assert.deepEqual( + findPendingAskToolCall(scannerContext([askToolCall("call-1")])), + { params, toolCallId: "call-1" } + ); + assert.deepEqual( + findPendingAskToolCall( + scannerContext([ + askToolCall("call-2"), + storedPayload("call-2", params, 99), + ]) + ), + { params, toolCallId: "call-2" } + ); + assert.equal( + findPendingAskToolCall( + scannerContext([askToolCall("call-3", { questions: [] })]) + ), + undefined + ); +}); + +test("pending ask scan skips a newer invalid call for an older valid call", () => { + const pending = findPendingAskToolCall( + scannerContext([ + askToolCall("valid-call"), + askToolCall("invalid-call", { questions: [] }), + ]) + ); + + assert.deepEqual(pending, { params, toolCallId: "valid-call" }); +}); + +test("pending ask scan ignores tool results and persisted dismissals", () => { + assert.equal( + findPendingAskToolCall( + scannerContext([askToolCall("call-1"), toolResult("call-1")]) + ), + undefined + ); + assert.equal( + findPendingAskToolCall( + scannerContext([askToolCall("call-2"), dismissed("call-2")]) + ), + undefined + ); +}); + +test("pending dismissal does not hide the payload from manual replay", () => { + const lookup = findLatestPayloadInCurrentBranch( + scannerContext([ + askToolCall("call-1"), + storedPayload("call-1"), + dismissed("call-1"), + ]), + "tool" + ); + + assert.equal(lookup.data?.params, params); +}); + +test("pending ask resume scans only startup, resume, and fork TUI events", () => { + let sessionStartHandler: ((event: any, ctx: any) => void) | undefined; + const remoteAsk = createRemoteAskRuntime(new TestEventBus() as never); + registerPendingAskResume( + { + on(event: string, handler: (event: any, ctx: any) => void) { + if (event === "session_start") { + sessionStartHandler = handler; + } + }, + } as never, + remoteAsk + ); + assert(sessionStartHandler); + + const branchReads: string[] = []; + for (const reason of ["startup", "resume", "fork", "new", "reload"]) { + sessionStartHandler( + { type: "session_start", reason }, + { + mode: "tui", + sessionManager: { + getBranch() { + branchReads.push(reason); + return []; + }, + }, + } + ); + } + + sessionStartHandler( + { type: "session_start", reason: "startup" }, + { + mode: "rpc", + sessionManager: { + getBranch() { + branchReads.push("rpc"); + return []; + }, + }, + } + ); + + assert.deepEqual(branchReads, ["startup", "resume", "fork"]); + remoteAsk.disposeAll(); +}); + +test("resumed submit persists dismissal, delivers an answer, and emits remote lifecycle events", { + timeout: 2000, +}, async () => { + getAskConfigStore().setConfig(disabledNotificationConfig()); + const branch: unknown[] = [askToolCall("call-1"), storedPayload("call-1")]; + const bus = new TestEventBus(); + const remoteAsk = createRemoteAskRuntime(bus as never); + const delivered: Array<{ text: string; options: unknown }> = []; + let resolveDelivery: (() => void) | undefined; + const delivery = new Promise((resolve) => { + resolveDelivery = resolve; + }); + const harness = createResumeHarness(branch, remoteAsk, { + idle: false, + onSend(text, options) { + delivered.push({ text, options }); + resolveDelivery?.(); + }, + }); + + bus.on(PI_ASK_STARTED_EVENT, (data) => { + const started = data as RemoteAskStartedEvent; + bus.emit(PI_ASK_SUBMIT_EVENT, { + version: 1, + requestId: "submit-1", + flowId: started.flowId, + response: { + kind: "answer", + answers: { engine: { values: ["canvas"] } }, + }, + }); + }); + + const handlerResult = harness.start("resume"); + assert.equal(handlerResult, undefined); + await delivery; + + assert.deepEqual(harness.dismissedToolCallIds, ["call-1"]); + assert.equal(delivered.length, 1); + assert.match(delivered[0].text, CANVAS_RE); + assert.deepEqual(delivered[0].options, { deliverAs: "followUp" }); + + const started = findEvent(bus, PI_ASK_STARTED_EVENT); + assert.equal(started.source, "ask:resume"); + assert.equal(started.toolCallId, "call-1"); + const completed = findEvent( + bus, + PI_ASK_COMPLETED_EVENT + ); + assert.equal(completed.source, "ask:resume"); + assert.equal(completed.result.cancelled, false); + + remoteAsk.disposeAll(); + getAskConfigStore().setConfig(DEFAULT_ASK_CONFIG); +}); + +test("resumed cancel persists dismissal and does not reopen on a second resume", { + timeout: 2000, +}, async () => { + getAskConfigStore().setConfig(disabledNotificationConfig()); + const branch: unknown[] = [askToolCall("call-1"), storedPayload("call-1")]; + const bus = new TestEventBus(); + const remoteAsk = createRemoteAskRuntime(bus as never); + let resolveCancellation: (() => void) | undefined; + const cancellation = new Promise((resolve) => { + resolveCancellation = resolve; + }); + const harness = createResumeHarness(branch, remoteAsk, { + onDismissNotice: () => resolveCancellation?.(), + }); + + bus.on(PI_ASK_STARTED_EVENT, (data) => { + const started = data as RemoteAskStartedEvent; + bus.emit(PI_ASK_SUBMIT_EVENT, { + version: 1, + requestId: "cancel-1", + flowId: started.flowId, + response: { kind: "cancel" }, + }); + }); + + harness.start("startup"); + await cancellation; + await new Promise((resolve) => setImmediate(resolve)); + harness.start("resume"); + await new Promise((resolve) => setImmediate(resolve)); + + assert.deepEqual(harness.dismissedToolCallIds, ["call-1"]); + assert.equal(harness.sentMessages.length, 0); + assert.equal( + bus.events.filter((event) => event.channel === PI_ASK_STARTED_EVENT).length, + 1 + ); + const completed = findEvent( + bus, + PI_ASK_COMPLETED_EVENT + ); + assert.equal(completed.source, "ask:resume"); + assert.equal(completed.result.cancelled, true); + + remoteAsk.disposeAll(); + getAskConfigStore().setConfig(DEFAULT_ASK_CONFIG); +}); + +function createResumeHarness( + branch: unknown[], + remoteAsk: ReturnType, + options: { + idle?: boolean; + onDismissNotice?: () => void; + onSend?: (text: string, sendOptions: unknown) => void; + } = {} +) { + let sessionStartHandler: ((event: any, ctx: any) => void) | undefined; + const dismissedToolCallIds: string[] = []; + const sentMessages: Array<{ text: string; options: unknown }> = []; + + registerPendingAskResume( + { + on(event: string, handler: (event: any, ctx: any) => void) { + if (event === "session_start") { + sessionStartHandler = handler; + } + }, + appendEntry(customType: string, data: { toolCallId: string }) { + branch.push({ type: "custom", customType, data }); + dismissedToolCallIds.push(data.toolCallId); + }, + sendUserMessage(text: string, sendOptions: unknown) { + sentMessages.push({ text, options: sendOptions }); + options.onSend?.(text, sendOptions); + }, + } as never, + remoteAsk + ); + assert(sessionStartHandler); + + const ctx = { + cwd: process.cwd(), + isIdle: () => options.idle ?? true, + mode: "tui", + sessionManager: { getBranch: () => branch }, + ui: { + custom(callback: (...args: any[]) => unknown) { + return new Promise((resolve) => { + callback( + { + requestRender() { + // Rendering is not needed for this lifecycle test. + }, + }, + plainTheme(), + {}, + resolve + ); + }); + }, + notify(message: string) { + if (message.startsWith("Unanswered ask_user form dismissed")) { + options.onDismissNotice?.(); + } + }, + setWorkingVisible() { + // The harness only checks completion behavior. + }, + }, + }; + + return { + dismissedToolCallIds, + sentMessages, + start(reason: "startup" | "resume" | "fork") { + return sessionStartHandler?.({ type: "session_start", reason }, ctx); + }, + }; +} + +function disabledNotificationConfig() { + return { + ...DEFAULT_ASK_CONFIG, + notifications: { ...DEFAULT_ASK_CONFIG.notifications, enabled: false }, + }; +} + +function findEvent(bus: TestEventBus, channel: string): T { + const event = bus.events.find((candidate) => candidate.channel === channel); + assert(event); + return event.data as T; +} + +function plainTheme() { + return { + bg(_color: string, text: string) { + return text; + }, + fg(_color: string, text: string) { + return text; + }, + }; +}