fix: restore pending asks after resume
This commit is contained in:
@@ -3,7 +3,7 @@
|
|||||||
# @eko24ive/pi-ask
|
# @eko24ive/pi-ask
|
||||||
|
|
||||||
[](https://www.npmjs.com/package/@eko24ive/pi-ask)
|
[](https://www.npmjs.com/package/@eko24ive/pi-ask)
|
||||||
[](https://github.com/eko24ive/pi-ask/commits/main)
|
[](https://github.com/eko24ive/pi-ask/commits/main)
|
||||||
[](https://github.com/eko24ive/pi-ask/stargazers)
|
[](https://github.com/eko24ive/pi-ask/stargazers)
|
||||||
|
|
||||||
> [!IMPORTANT]
|
> [!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` extracts questions from the latest assistant message into an ask flow
|
||||||
- `/answer:again` reopens the latest `/answer` form on the current branch
|
- `/answer:again` reopens the latest `/answer` form on the current branch
|
||||||
- `/ask:replay` replays the latest real `ask_user` 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
|
- 🗣️ 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
|
## 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.
|
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.
|
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.
|
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.
|
||||||
|
|||||||
@@ -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-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/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/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/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/remote-ask.ts` — package-prefixed local event contract, active-flow registry, and explicit remote submission validation
|
||||||
- `src/schema.ts` — TypeBox schema
|
- `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
|
- 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
|
- 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
|
- 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 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
|
- 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
|
- 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
|
||||||
|
|||||||
@@ -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` 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
|
- `/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
|
- `/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
|
- 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
|
- `?` 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
|
- 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
|
- 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
|
- 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.
|
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)
|
## Skill alignment (advisory)
|
||||||
|
|||||||
@@ -25,14 +25,14 @@ type PiAskStartedEvent = {
|
|||||||
version: 1;
|
version: 1;
|
||||||
flowId: string;
|
flowId: string;
|
||||||
toolCallId?: string;
|
toolCallId?: string;
|
||||||
source: "tool" | "answer" | "answer:again" | "ask:replay";
|
source: "tool" | "answer" | "answer:again" | "ask:replay" | "ask:resume";
|
||||||
title?: string;
|
title?: string;
|
||||||
questions: AskQuestion[];
|
questions: AskQuestion[];
|
||||||
createdAt: number;
|
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
|
## Submit an answer
|
||||||
|
|
||||||
@@ -111,7 +111,7 @@ type PiAskCompletedEvent = {
|
|||||||
version: 1;
|
version: 1;
|
||||||
flowId: string;
|
flowId: string;
|
||||||
toolCallId?: string;
|
toolCallId?: string;
|
||||||
source: "tool" | "answer" | "answer:again" | "ask:replay";
|
source: "tool" | "answer" | "answer:again" | "ask:replay" | "ask:resume";
|
||||||
result: AskResult;
|
result: AskResult;
|
||||||
completedAt: number;
|
completedAt: number;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -50,6 +50,26 @@ export function findLatestPayloadInCurrentBranch(
|
|||||||
return { invalidMatchFound };
|
return { invalidMatchFound };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function findPayloadForSourceEntry(
|
||||||
|
ctx: Pick<ExtensionContext, "sessionManager">,
|
||||||
|
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 {
|
function isAskPayloadEntry(entry: unknown): entry is {
|
||||||
customType: string;
|
customType: string;
|
||||||
data?: Partial<AskPayloadEntryData>;
|
data?: Partial<AskPayloadEntryData>;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { registerAskSettingsCommand } from "./ask-settings-command.ts";
|
|||||||
import { registerAskTool } from "./ask-tool.ts";
|
import { registerAskTool } from "./ask-tool.ts";
|
||||||
import { resetAskConfigStore } from "./config/store.ts";
|
import { resetAskConfigStore } from "./config/store.ts";
|
||||||
import { createRemoteAskRuntime } from "./remote-ask.ts";
|
import { createRemoteAskRuntime } from "./remote-ask.ts";
|
||||||
|
import { registerPendingAskResume } from "./resume-pending-ask.ts";
|
||||||
|
|
||||||
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
const CONFIGURATION_DOC_PATH = resolve(
|
const CONFIGURATION_DOC_PATH = resolve(
|
||||||
@@ -27,4 +28,5 @@ export default function askExtension(pi: ExtensionAPI) {
|
|||||||
registerAskTool(pi, remoteAsk);
|
registerAskTool(pi, remoteAsk);
|
||||||
registerAskSettingsCommand(pi);
|
registerAskSettingsCommand(pi);
|
||||||
registerAnswerCommands(pi, remoteAsk);
|
registerAnswerCommands(pi, remoteAsk);
|
||||||
|
registerPendingAskResume(pi, remoteAsk);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<ExtensionAPI, "appendEntry">,
|
||||||
|
toolCallId: string
|
||||||
|
): void {
|
||||||
|
pi.appendEntry(ASK_PENDING_DISMISSED_ENTRY_TYPE, { toolCallId });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findPendingAskToolCall(
|
||||||
|
ctx: Pick<ExtensionContext, "sessionManager">
|
||||||
|
): 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<string> {
|
||||||
|
const resolved = new Set<string>();
|
||||||
|
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<string>
|
||||||
|
): 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<ExtensionContext, "sessionManager">,
|
||||||
|
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;
|
||||||
|
}
|
||||||
+6
-1
@@ -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_EVENT = "@eko24ive/pi-ask:submit";
|
||||||
export const PI_ASK_SUBMIT_RESULT_EVENT = "@eko24ive/pi-ask:submit-result";
|
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 {
|
export interface RemoteAskAnswer {
|
||||||
customText?: string;
|
customText?: string;
|
||||||
|
|||||||
@@ -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<SessionStartEvent["reason"]> = 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<ExtensionAPI, "appendEntry" | "sendUserMessage">,
|
||||||
|
ctx: ExtensionContext,
|
||||||
|
pendingAsk: PendingAskToolCall,
|
||||||
|
remoteAsk: RemoteAskRuntime
|
||||||
|
): Promise<void> {
|
||||||
|
ctx.ui.notify(
|
||||||
|
`Reopening unanswered ask_user form: ${pendingAsk.params.questions.length} question(s).`,
|
||||||
|
"info"
|
||||||
|
);
|
||||||
|
ctx.ui.setWorkingVisible(false);
|
||||||
|
|
||||||
|
let result: Awaited<ReturnType<typeof runAskFlow>>;
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -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<string, Array<(data: unknown) => 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<void>((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<RemoteAskStartedEvent>(bus, PI_ASK_STARTED_EVENT);
|
||||||
|
assert.equal(started.source, "ask:resume");
|
||||||
|
assert.equal(started.toolCallId, "call-1");
|
||||||
|
const completed = findEvent<RemoteAskCompletedEvent>(
|
||||||
|
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<void>((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<void>((resolve) => setImmediate(resolve));
|
||||||
|
harness.start("resume");
|
||||||
|
await new Promise<void>((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<RemoteAskCompletedEvent>(
|
||||||
|
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<typeof createRemoteAskRuntime>,
|
||||||
|
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<T>(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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user