feat: add remote ask event contract

Inspired-by: @S1M0N38 in PR #6
This commit is contained in:
Erik Kotsiuba
2026-06-22 01:10:11 +02:00
parent 50b8bd0da3
commit 69e8bab6ef
12 changed files with 1136 additions and 14 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
# @eko24ive/pi-ask # @eko24ive/pi-ask
[![npm downloads](https://badgen.net/npm/dm/@eko24ive/pi-ask)](https://www.npmjs.com/package/@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=bf3c26d)](https://github.com/eko24ive/pi-ask/commits/main) [![last commit](https://badgen.net/github/last-commit/eko24ive/pi-ask?v=fa89c15)](https://github.com/eko24ive/pi-ask/commits/main)
[![stars](https://badgen.net/github/stars/eko24ive/pi-ask)](https://github.com/eko24ive/pi-ask/stargazers) [![stars](https://badgen.net/github/stars/eko24ive/pi-ask)](https://github.com/eko24ive/pi-ask/stargazers)
`@eko24ive/pi-ask` is an ask tool that cares about your answers. `@eko24ive/pi-ask` is an ask tool that cares about your answers.
+3 -1
View File
@@ -5,7 +5,8 @@ This folder keeps only the documentation needed to understand and maintain the e
## Files ## Files
- `configuration.md` — source of truth for configuring pi-ask keymaps, behaviour, and `/answer` extraction - `configuration.md` — source of truth for configuring pi-ask keymaps, behaviour, and `/answer` extraction
- `contract.md` — external behavior, full tool payload/result details, and UX guarantees - `contract.md` — external behavior, tool payload/result details, and UX guarantees
- `remote-events.md` — local inter-extension event contract, bridge examples, and smoke-test steps
- `architecture.md` — module boundaries and invariants - `architecture.md` — module boundaries and invariants
- `../skills/ask-user/SKILL.md` — auto-bundled agent-side decision-gate guidance; enabled by default, but can be disabled via `pi config`; inspired by https://github.com/edlsh/pi-ask-user - `../skills/ask-user/SKILL.md` — auto-bundled agent-side decision-gate guidance; enabled by default, but can be disabled via `pi config`; inspired by https://github.com/edlsh/pi-ask-user
@@ -13,6 +14,7 @@ This folder keeps only the documentation needed to understand and maintain the e
- start with `configuration.md` for config-editing rules - start with `configuration.md` for config-editing rules
- read `contract.md` for behavior - read `contract.md` for behavior
- read `remote-events.md` for local bridge/event integration
- read `architecture.md` for code layout - read `architecture.md` for code layout
## Local test run ## Local test run
+2
View File
@@ -19,6 +19,7 @@ The codebase is split so the implementation reads through file boundaries and na
- `src/answer-extraction.ts` — configured extraction model selection and raw-JSON extraction retries - `src/answer-extraction.ts` — configured extraction model selection and raw-JSON extraction retries
- `src/ask-payload-store.ts` — branch-aware persisted ask payload lookup - `src/ask-payload-store.ts` — branch-aware persisted ask payload lookup
- `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/schema.ts` — TypeBox schema - `src/schema.ts` — TypeBox schema
- `src/types.ts` — shared types - `src/types.ts` — shared types
@@ -86,6 +87,7 @@ The codebase is split so the implementation reads through file boundaries and na
- legacy root config files are read as a fallback only when the current config file is absent; disk is left untouched - legacy root config files are read as a fallback only when the current config file is absent; disk is left untouched
- invalid config files are left untouched; defaults are loaded for the session with a notice - invalid config files are left untouched; defaults are loaded for the session with a notice
- live config updates can affect an in-progress ask flow immediately - live config updates can affect an in-progress ask flow immediately
- remote ask submissions must be explicit `answer` or `cancel` responses; pi-ask validates ids/values but never infers approve/deny semantics from labels or option values
## Documentation rule ## Documentation rule
+15
View File
@@ -267,6 +267,21 @@ The ask flow subscribes to runtime settings updates while open. In practice, thi
When enabled, pi-ask emits one best-effort external notification per ask session after the ask UI opens and waits for input. The default title is `pi ask`; the message is `Question waiting: <label or prompt>`. Channels run in configured order and failures never fail or cancel the ask flow. When enabled, pi-ask emits one best-effort external notification per ask session after the ask UI opens and waits for input. The default title is `pi ask`; the message is `Question waiting: <label or prompt>`. Channels run in configured order and failures never fail or cancel the ask flow.
## Remote inter-extension events
pi-ask exposes a local `pi.events` contract for trusted Pi extensions. It does not expose a network API and does not automate terminal keystrokes.
Channels:
- `@eko24ive/pi-ask:started`
- `@eko24ive/pi-ask:completed`
- `@eko24ive/pi-ask:submit`
- `@eko24ive/pi-ask:submit-result`
Remote submissions must be explicit `{ kind: "answer" }` or `{ kind: "cancel" }` responses. Remote answers use question ids and normalized option values from the started event. pi-ask validates ids and values, recomputes labels/indices, and does not infer approval semantics from labels.
See [`remote-events.md`](remote-events.md) for payload shapes, examples, and a local smoke test.
## Slash command replay/extraction ## Slash command replay/extraction
- valid `ask_user` payloads are persisted as branch custom entries before the UI opens, so `/ask:replay` can reopen them after cancel, `/resume`, or `/tree` - valid `ask_user` payloads are persisted as branch custom entries before the UI opens, so `/ask:replay` can reopen them after cancel, `/resume`, or `/tree`
+187
View File
@@ -0,0 +1,187 @@
# Remote ask events
pi-ask exposes a local `pi.events` contract for trusted Pi extensions that run in the same Pi process.
Use this for local bridges: status cards, desktop helpers, or approval UIs. Do not use terminal keystroke automation. pi-ask does not expose a network API.
## Channels
Lifecycle:
- `@eko24ive/pi-ask:started`
- `@eko24ive/pi-ask:completed`
Remote submit:
- `@eko24ive/pi-ask:submit`
- `@eko24ive/pi-ask:submit-result`
## Started
Emitted after a validated ask UI flow opens.
```ts
type PiAskStartedEvent = {
version: 1;
flowId: string;
toolCallId?: string;
source: "tool" | "answer" | "answer:again" | "ask:replay";
title?: string;
questions: AskQuestion[];
createdAt: number;
};
```
Use `flowId` for submit/correlation. Use `questions[].id` and `questions[].options[].value` for answers.
## Submit an answer
```ts
pi.events.emit("@eko24ive/pi-ask:submit", {
version: 1,
requestId: `bridge-${Date.now()}`,
flowId,
response: {
kind: "answer",
mode: "submit",
answers: {
questionId: { values: ["option-value"] },
},
},
});
```
Answer shape:
```ts
type PiAskRemoteAnswer = {
values?: string[];
customText?: string;
note?: string;
optionNotes?: Record<string, string>;
};
```
Rules:
- `values` must match option `value`s from the started event
- keys in `answers` must match question ids
- labels and indices are recomputed by pi-ask
- a remote `answer` replaces the current answer set; stale UI answers are not merged
- `mode` defaults to `"submit"`; use `"elaborate"` to complete as an elaboration request
## Cancel
```ts
pi.events.emit("@eko24ive/pi-ask:submit", {
version: 1,
requestId: `bridge-${Date.now()}`,
flowId,
response: { kind: "cancel" },
});
```
Cancel must be explicit. pi-ask does not infer cancel/approve/deny from labels or button names.
## Submit result
After a submit request, pi-ask emits:
```ts
type PiAskSubmitResultEvent =
| { version: 1; requestId: string; flowId: string; ok: true }
| {
version: 1;
requestId: string;
flowId: string;
ok: false;
error: "flow_not_found" | "invalid_request" | "invalid_answer";
message: string;
};
```
Correlate by `requestId` and `flowId`. Do not depend on strict ordering between `submit-result` and `completed`.
## Completed
Emitted when the flow resolves.
```ts
type PiAskCompletedEvent = {
version: 1;
flowId: string;
toolCallId?: string;
source: "tool" | "answer" | "answer:again" | "ask:replay";
result: AskResult;
completedAt: number;
};
```
## Minimal bridge
```ts
export default function piAskBridge(pi: any) {
pi.events.on("@eko24ive/pi-ask:started", (event: any) => {
const question = event.questions[0];
const option = question.options[0];
pi.events.emit("@eko24ive/pi-ask:submit", {
version: 1,
requestId: `bridge-${Date.now()}`,
flowId: event.flowId,
response: {
kind: "answer",
answers: {
[question.id]: { values: [option.value] },
},
},
});
});
pi.events.on("@eko24ive/pi-ask:submit-result", (event: any) => {
if (!event.ok) console.error(event.error, event.message);
});
}
```
Third-party integrations own their own UI policy and mappings. For example, a bridge may map a button to `{ values: ["yes"] }`, but pi-ask will never guess that mapping from the label.
## Local smoke test
Create a temporary bridge and run pi with only this repo extension plus the bridge:
```bash
cat > /tmp/pi-ask-smoke.ts <<'EOF'
export default function smoke(pi: any) {
pi.events.on("@eko24ive/pi-ask:started", (event: any) => {
const q = event.questions[0];
setTimeout(() => {
pi.events.emit("@eko24ive/pi-ask:submit", {
version: 1,
requestId: `smoke-${Date.now()}`,
flowId: event.flowId,
response: { kind: "answer", answers: { [q.id]: { values: ["tool"] } } },
});
}, 2500);
});
}
EOF
pi \
--no-extensions \
--no-skills \
--no-prompt-templates \
--no-themes \
--no-context-files \
-e "$PWD/src/index.ts" \
-e /tmp/pi-ask-smoke.ts \
--skill "$PWD/skills/ask-user"
```
Then ask Pi:
```txt
Use ask_user. Title: pi-ask smoke. Ask one single-select question id tool with options tool and nope.
```
The ask UI should open and auto-submit `tool` after about 2.5 seconds.
+2
View File
@@ -24,6 +24,8 @@
"src/", "src/",
"skills/", "skills/",
"docs/configuration.md", "docs/configuration.md",
"docs/contract.md",
"docs/remote-events.md",
"README.md", "README.md",
"LICENSE", "LICENSE",
"CHANGELOG.md" "CHANGELOG.md"
+29 -5
View File
@@ -19,6 +19,7 @@ import {
validateParams, validateParams,
} from "./ask-tool-helpers.ts"; } from "./ask-tool-helpers.ts";
import { getAskConfigStore } from "./config/store.ts"; import { getAskConfigStore } from "./config/store.ts";
import type { RemoteAskRuntime, RemoteAskSource } from "./remote-ask.ts";
import type { AskParams, AskResult } from "./types.ts"; import type { AskParams, AskResult } from "./types.ts";
import { runAskFlow } from "./ui/controller.ts"; import { runAskFlow } from "./ui/controller.ts";
@@ -37,11 +38,14 @@ type ExtractionUiResult =
| { error: string } | { error: string }
| { params: AskParams }; | { params: AskParams };
export function registerAnswerCommands(pi: ExtensionAPI): void { export function registerAnswerCommands(
pi: ExtensionAPI,
remoteAsk?: RemoteAskRuntime
): void {
pi.registerCommand("answer", { pi.registerCommand("answer", {
description: description:
"Extract questions from the latest assistant message into an ask form", "Extract questions from the latest assistant message into an ask form",
handler: async (_args, ctx) => runAnswerCommand(pi, ctx), handler: async (_args, ctx) => runAnswerCommand(pi, ctx, remoteAsk),
}); });
pi.registerCommand("answer:again", { pi.registerCommand("answer:again", {
@@ -51,7 +55,9 @@ export function registerAnswerCommands(pi: ExtensionAPI): void {
missingMessage: missingMessage:
"No previous /answer form found on this branch; use /answer first.", "No previous /answer form found on this branch; use /answer first.",
noticePrefix: "Reopening previous /answer form on this branch", noticePrefix: "Reopening previous /answer form on this branch",
remoteSource: "answer:again",
source: "answer-extraction", source: "answer-extraction",
remoteAsk,
}), }),
}); });
@@ -61,14 +67,17 @@ export function registerAnswerCommands(pi: ExtensionAPI): void {
runReplayCommand(pi, ctx, { runReplayCommand(pi, ctx, {
missingMessage: "No previous ask_user form found on this branch.", missingMessage: "No previous ask_user form found on this branch.",
noticePrefix: "Replaying previous ask_user form on this branch", noticePrefix: "Replaying previous ask_user form on this branch",
remoteSource: "ask:replay",
source: "tool", source: "tool",
remoteAsk,
}), }),
}); });
} }
async function runAnswerCommand( async function runAnswerCommand(
pi: ExtensionAPI, pi: ExtensionAPI,
ctx: ExtensionCommandContext ctx: ExtensionCommandContext,
remoteAsk?: RemoteAskRuntime
): Promise<void> { ): Promise<void> {
if (!ctx.hasUI) { if (!ctx.hasUI) {
ctx.ui.notify("/answer requires interactive UI.", "error"); ctx.ui.notify("/answer requires interactive UI.", "error");
@@ -115,6 +124,8 @@ async function runAnswerCommand(
await runAskAndSendSubmittedResult(pi, ctx, params, { await runAskAndSendSubmittedResult(pi, ctx, params, {
allowFreeform: true, allowFreeform: true,
remoteAsk,
remoteSource: "answer",
}); });
} }
@@ -237,6 +248,8 @@ async function runReplayCommand(
options: { options: {
missingMessage: string; missingMessage: string;
noticePrefix: string; noticePrefix: string;
remoteAsk?: RemoteAskRuntime;
remoteSource: RemoteAskSource;
source: AskPayloadSource; source: AskPayloadSource;
} }
): Promise<void> { ): Promise<void> {
@@ -260,6 +273,8 @@ async function runReplayCommand(
); );
await runAskAndSendSubmittedResult(pi, ctx, lookup.data.params, { await runAskAndSendSubmittedResult(pi, ctx, lookup.data.params, {
allowFreeform: options.source === "answer-extraction", allowFreeform: options.source === "answer-extraction",
remoteAsk: options.remoteAsk,
remoteSource: options.remoteSource,
}); });
} }
@@ -267,10 +282,19 @@ async function runAskAndSendSubmittedResult(
pi: Pick<ExtensionAPI, "sendUserMessage">, pi: Pick<ExtensionAPI, "sendUserMessage">,
ctx: ExtensionContext, ctx: ExtensionContext,
params: AskParams, params: AskParams,
options: { allowFreeform: boolean } options: {
allowFreeform: boolean;
remoteAsk?: RemoteAskRuntime;
remoteSource: RemoteAskSource;
}
): Promise<void> { ): Promise<void> {
const result = await withHiddenWorkingRow(ctx, () => const result = await withHiddenWorkingRow(ctx, () =>
runAskFlow(ctx, params, options) runAskFlow(ctx, params, {
allowFreeform: options.allowFreeform,
remote: options.remoteAsk
? { runtime: options.remoteAsk, source: options.remoteSource }
: undefined,
})
); );
if (result.cancelled) { if (result.cancelled) {
ctx.ui.notify("Ask form cancelled.", "info"); ctx.ui.notify("Ask form cancelled.", "info");
+14 -4
View File
@@ -14,11 +14,15 @@ import {
validateParams, validateParams,
} from "./ask-tool-helpers.ts"; } from "./ask-tool-helpers.ts";
import { getAskConfigStore } from "./config/store.ts"; import { getAskConfigStore } from "./config/store.ts";
import type { RemoteAskRuntime } from "./remote-ask.ts";
import { AskParamsSchema } from "./schema.ts"; import { AskParamsSchema } from "./schema.ts";
import type { AskParams } from "./types.ts"; import type { AskParams } from "./types.ts";
import { runAskFlow } from "./ui/controller.ts"; import { runAskFlow } from "./ui/controller.ts";
export function registerAskTool(pi: ExtensionAPI) { export function registerAskTool(
pi: ExtensionAPI,
remoteAsk?: RemoteAskRuntime
) {
pi.registerTool({ pi.registerTool({
name: "ask_user", name: "ask_user",
label: "Ask User", label: "Ask User",
@@ -34,7 +38,8 @@ export function registerAskTool(pi: ExtensionAPI) {
params as AskParams, params as AskParams,
signal, signal,
onUpdate, onUpdate,
ctx ctx,
remoteAsk
), ),
renderCall: renderAskToolCall, renderCall: renderAskToolCall,
renderResult: renderAskToolResult, renderResult: renderAskToolResult,
@@ -47,7 +52,8 @@ async function executeAskTool(
params: AskParams, params: AskParams,
_signal: AbortSignal | undefined, _signal: AbortSignal | undefined,
_onUpdate: unknown, _onUpdate: unknown,
ctx: ExtensionContext ctx: ExtensionContext,
remoteAsk?: RemoteAskRuntime
) { ) {
const config = await getAskConfigStore().getConfig(); const config = await getAskConfigStore().getConfig();
const validation = validateParams(params, { const validation = validateParams(params, {
@@ -66,7 +72,11 @@ async function executeAskTool(
} }
ctx.ui.setWorkingVisible(false); ctx.ui.setWorkingVisible(false);
try { try {
const result = await runAskFlow(ctx, params); const result = await runAskFlow(ctx, params, {
remote: remoteAsk
? { runtime: remoteAsk, source: "tool", toolCallId }
: undefined,
});
return successfulResponse(result); return successfulResponse(result);
} finally { } finally {
ctx.ui.setWorkingVisible(true); ctx.ui.setWorkingVisible(true);
+4 -2
View File
@@ -4,6 +4,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { registerAnswerCommands } from "./answer-commands.ts"; import { registerAnswerCommands } from "./answer-commands.ts";
import { registerAskSettingsCommand } from "./ask-settings-command.ts"; import { registerAskSettingsCommand } from "./ask-settings-command.ts";
import { registerAskTool } from "./ask-tool.ts"; import { registerAskTool } from "./ask-tool.ts";
import { createRemoteAskRuntime } from "./remote-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(
@@ -17,7 +18,8 @@ export default function askExtension(pi: ExtensionAPI) {
pi.on("before_agent_start", async (event) => ({ pi.on("before_agent_start", async (event) => ({
systemPrompt: `${event.systemPrompt}\n\n${PI_ASK_CONFIG_PROMPT}`, systemPrompt: `${event.systemPrompt}\n\n${PI_ASK_CONFIG_PROMPT}`,
})); }));
registerAskTool(pi); const remoteAsk = createRemoteAskRuntime(pi.events);
registerAskTool(pi, remoteAsk);
registerAskSettingsCommand(pi); registerAskSettingsCommand(pi);
registerAnswerCommands(pi); registerAnswerCommands(pi, remoteAsk);
} }
+559
View File
@@ -0,0 +1,559 @@
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import type {
AskQuestion,
AskResult,
AskSelectedOption,
AskState,
AskStateAnswer,
} from "./types.ts";
export const PI_ASK_STARTED_EVENT = "@eko24ive/pi-ask:started";
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 interface RemoteAskAnswer {
customText?: string;
note?: string;
optionNotes?: Record<string, string>;
values?: string[];
}
export type RemoteAskResponse =
| {
answers: Record<string, RemoteAskAnswer>;
kind: "answer";
mode?: "submit" | "elaborate";
}
| { kind: "cancel" };
export interface RemoteAskStartedEvent {
createdAt: number;
flowId: string;
questions: AskQuestion[];
source: RemoteAskSource;
title?: string;
toolCallId?: string;
version: 1;
}
export interface RemoteAskCompletedEvent {
completedAt: number;
flowId: string;
result: AskResult;
source: RemoteAskSource;
toolCallId?: string;
version: 1;
}
export interface RemoteAskSubmitEvent {
flowId: string;
requestId: string;
response: RemoteAskResponse;
version: 1;
}
export type RemoteAskSubmitError =
| "flow_not_found"
| "invalid_answer"
| "invalid_request";
export type RemoteAskSubmitResultEvent = {
flowId: string;
requestId: string;
version: 1;
} & (
| { ok: true }
| { error: RemoteAskSubmitError; message: string; ok: false }
);
export interface RemoteAskFlowOptions {
source: RemoteAskSource;
toolCallId?: string;
}
export interface RemoteAskFlowInput extends RemoteAskFlowOptions {
onSubmit: (response: RemoteAskResponse) => RemoteAskSubmitResolution;
questions: AskQuestion[];
title?: string;
}
interface ActiveRemoteAskFlow extends RemoteAskFlowInput {
flowId: string;
}
export type RemoteAskSubmitResolution =
| { ok: true }
| { error: RemoteAskSubmitError; message: string; ok: false };
export interface RemoteAskFlowHandle {
complete: (result: AskResult) => void;
dispose: () => void;
flowId: string;
}
export interface RemoteAskRuntime {
startFlow: (flow: RemoteAskFlowInput) => RemoteAskFlowHandle;
}
type EventBus = ExtensionAPI["events"];
export function createRemoteAskRuntime(events: EventBus): RemoteAskRuntime {
const activeFlows = new Map<string, ActiveRemoteAskFlow>();
events.on(PI_ASK_SUBMIT_EVENT, (data) => {
handleSubmitEvent(events, activeFlows, data);
});
return {
startFlow(flow) {
return startRemoteAskFlow(events, activeFlows, flow);
},
};
}
function handleSubmitEvent(
events: EventBus,
activeFlows: Map<string, ActiveRemoteAskFlow>,
data: unknown
): void {
const request = parseSubmitEvent(data);
if (!request.ok) {
emitSubmitResult(events, {
version: 1,
requestId: request.requestId,
flowId: request.flowId,
ok: false,
error: "invalid_request",
message: request.message,
});
return;
}
const event = request.event;
const flow = activeFlows.get(event.flowId);
if (!flow) {
emitSubmitResult(events, failedSubmit(event));
return;
}
emitSubmitResult(events, {
version: 1,
requestId: event.requestId,
flowId: event.flowId,
...flow.onSubmit(event.response),
});
}
function startRemoteAskFlow(
events: EventBus,
activeFlows: Map<string, ActiveRemoteAskFlow>,
flow: RemoteAskFlowInput
): RemoteAskFlowHandle {
const flowId = createFlowId(flow.toolCallId);
let completed = false;
activeFlows.set(flowId, { ...flow, flowId });
queueMicrotask(() => {
if (completed || !activeFlows.has(flowId)) {
return;
}
events.emit(PI_ASK_STARTED_EVENT, {
version: 1,
flowId,
toolCallId: flow.toolCallId,
source: flow.source,
title: flow.title,
questions: cloneQuestions(flow.questions),
createdAt: Date.now(),
} satisfies RemoteAskStartedEvent);
});
return {
flowId,
complete(result) {
if (completed) {
return;
}
completed = true;
activeFlows.delete(flowId);
events.emit(PI_ASK_COMPLETED_EVENT, {
version: 1,
flowId,
toolCallId: flow.toolCallId,
source: flow.source,
result,
completedAt: Date.now(),
} satisfies RemoteAskCompletedEvent);
},
dispose() {
if (completed) {
return;
}
activeFlows.delete(flowId);
},
};
}
function cloneQuestions(questions: AskQuestion[]): AskQuestion[] {
return questions.map((question) => ({
...question,
options: question.options.map((option) => ({ ...option })),
}));
}
function emitSubmitResult(
events: EventBus,
result: RemoteAskSubmitResultEvent
): void {
events.emit(PI_ASK_SUBMIT_RESULT_EVENT, result);
}
function failedSubmit(event: RemoteAskSubmitEvent): RemoteAskSubmitResultEvent {
return {
version: 1,
requestId: event.requestId,
flowId: event.flowId,
ok: false,
error: "flow_not_found",
message: "Ask flow is not active.",
};
}
export function applyRemoteAskResponse(
state: AskState,
response: RemoteAskResponse
): { state: AskState } & RemoteAskSubmitResolution {
if (response.kind === "cancel") {
return {
ok: true,
state: { ...state, cancelled: true, completed: true },
};
}
const validation = validateAnswerResponse(state, response);
if (!validation.ok) {
return { ...validation, state };
}
return {
ok: true,
state: {
...state,
answers: validation.answers,
activeTabIndex: state.questions.length,
activeSubmitActionIndex: response.mode === "elaborate" ? 1 : 0,
completed: true,
mode: response.mode ?? "submit",
view: { kind: "submit" },
},
};
}
function validateAnswerResponse(
state: AskState,
response: Extract<RemoteAskResponse, { kind: "answer" }>
):
| { answers: Record<string, AskStateAnswer>; ok: true }
| { error: "invalid_answer"; message: string; ok: false } {
if (!isPlainObject(response.answers)) {
return invalidAnswer("Answer response must include an answers object.");
}
if (
response.mode &&
response.mode !== "submit" &&
response.mode !== "elaborate"
) {
return invalidAnswer('Answer mode must be "submit" or "elaborate".');
}
const answers: Record<string, AskStateAnswer> = {};
for (const [questionId, answer] of Object.entries(response.answers)) {
const question = state.questions.find(
(candidate) => candidate.id === questionId
);
if (!question) {
return invalidAnswer(`Unknown question id "${questionId}".`);
}
const normalized = normalizeRemoteAnswer(question, answer);
if (!normalized.ok) {
return normalized;
}
answers[questionId] = normalized.answer;
}
return { ok: true, answers };
}
function normalizeRemoteAnswer(
question: AskQuestion,
answer: unknown
):
| { answer: AskStateAnswer; ok: true }
| { error: "invalid_answer"; message: string; ok: false } {
const parts = parseRemoteAnswerParts(question, answer);
if (!parts.ok) {
return parts;
}
const selected = normalizeSelectedValues(question, parts.values);
if (!selected.ok) {
return selected;
}
const optionNotes = normalizeOptionNotes(question, parts.optionNotes);
if (!optionNotes.ok) {
return optionNotes;
}
const trimmedCustomText = parts.customText?.trim();
return {
ok: true,
answer: {
selected: selected.selected,
customSelected: trimmedCustomText ? true : undefined,
customText: trimmedCustomText ? parts.customText : undefined,
note: parts.note?.trim() ? parts.note : undefined,
optionNotes: optionNotes.optionNotes,
},
};
}
function parseRemoteAnswerParts(
question: AskQuestion,
answer: unknown
):
| {
customText?: string;
note?: string;
ok: true;
optionNotes?: Record<string, string>;
values: string[];
}
| { error: "invalid_answer"; message: string; ok: false } {
if (!isPlainObject(answer)) {
return invalidAnswer(
`Answer for question "${question.id}" must be an object.`
);
}
if (answer.values !== undefined && !isStringArray(answer.values)) {
return invalidAnswer(
`Answer values for question "${question.id}" must be strings.`
);
}
const values = answer.values ?? [];
const scalarError = validateRemoteAnswerScalars(question, answer, values);
if (scalarError) {
return scalarError;
}
return {
ok: true,
values,
customText: answer.customText as string | undefined,
note: answer.note as string | undefined,
optionNotes: answer.optionNotes as Record<string, string> | undefined,
};
}
function validateRemoteAnswerScalars(
question: AskQuestion,
answer: Record<string, unknown>,
values: string[]
): { error: "invalid_answer"; message: string; ok: false } | undefined {
if (new Set(values).size !== values.length) {
return invalidAnswer(
`Answer values for question "${question.id}" must be unique.`
);
}
if (question.type !== "multi" && values.length > 1) {
return invalidAnswer(
`Question "${question.id}" accepts only one selected value.`
);
}
if (
answer.customText !== undefined &&
typeof answer.customText !== "string"
) {
return invalidAnswer(
`Custom text for question "${question.id}" must be a string.`
);
}
if (
typeof answer.customText === "string" &&
answer.customText.trim() &&
question.type !== "multi" &&
values.length > 0
) {
return invalidAnswer(
`Question "${question.id}" cannot combine a selected value and custom text.`
);
}
if (answer.note !== undefined && typeof answer.note !== "string") {
return invalidAnswer(
`Note for question "${question.id}" must be a string.`
);
}
if (answer.optionNotes !== undefined && !isStringRecord(answer.optionNotes)) {
return invalidAnswer(
`Option notes for question "${question.id}" must be string values.`
);
}
}
function normalizeSelectedValues(
question: AskQuestion,
values: string[]
):
| { ok: true; selected: AskSelectedOption[] }
| { error: "invalid_answer"; message: string; ok: false } {
const selected: AskSelectedOption[] = [];
for (const value of values) {
const optionIndex = question.options.findIndex(
(option) => option.value === value
);
if (optionIndex < 0) {
return invalidAnswer(
`Unknown option value "${value}" for question "${question.id}".`
);
}
const option = question.options[optionIndex];
selected.push({
value: option.value,
label: option.label,
index: optionIndex + 1,
});
}
return { ok: true, selected };
}
function normalizeOptionNotes(
question: AskQuestion,
optionNotes: Record<string, string> | undefined
):
| { ok: true; optionNotes?: Record<string, string> }
| { error: "invalid_answer"; message: string; ok: false } {
const entries = Object.entries(optionNotes ?? {}).filter(([, value]) =>
value.trim()
);
for (const [value] of entries) {
if (!question.options.some((option) => option.value === value)) {
return invalidAnswer(
`Unknown option note value "${value}" for question "${question.id}".`
);
}
}
return {
ok: true,
optionNotes: entries.length > 0 ? Object.fromEntries(entries) : undefined,
};
}
function parseSubmitEvent(
data: unknown
):
| { event: RemoteAskSubmitEvent; ok: true }
| { flowId: string; message: string; ok: false; requestId: string } {
const fallback = { requestId: "", flowId: "" };
if (!isPlainObject(data)) {
return {
...fallback,
ok: false,
message: "Submit event must be an object.",
};
}
const requestId = typeof data.requestId === "string" ? data.requestId : "";
const flowId = typeof data.flowId === "string" ? data.flowId : "";
if (data.version !== 1) {
return {
requestId,
flowId,
ok: false,
message: "Submit event version must be 1.",
};
}
if (!requestId) {
return {
requestId,
flowId,
ok: false,
message: "Submit event requestId is required.",
};
}
if (!flowId) {
return {
requestId,
flowId,
ok: false,
message: "Submit event flowId is required.",
};
}
const response = parseRemoteResponse(data.response);
if (!response.ok) {
return { requestId, flowId, ok: false, message: response.message };
}
return {
ok: true,
event: {
version: 1,
requestId,
flowId,
response: response.response,
},
};
}
function parseRemoteResponse(
data: unknown
): { ok: true; response: RemoteAskResponse } | { message: string; ok: false } {
if (!isPlainObject(data)) {
return { ok: false, message: "Submit response must be an object." };
}
if (data.kind === "cancel") {
return { ok: true, response: { kind: "cancel" } };
}
if (data.kind === "answer") {
return {
ok: true,
response: {
kind: "answer",
answers: data.answers as Record<string, RemoteAskAnswer>,
mode: data.mode as "submit" | "elaborate" | undefined,
},
};
}
return {
ok: false,
message: 'Submit response kind must be "answer" or "cancel".',
};
}
function invalidAnswer(message: string): {
error: "invalid_answer";
message: string;
ok: false;
} {
return { ok: false, error: "invalid_answer", message };
}
function createFlowId(toolCallId?: string): string {
if (toolCallId) {
return `tool:${toolCallId}`;
}
return `flow:${Date.now()}:${Math.random().toString(36).slice(2)}`;
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function isStringArray(value: unknown): value is string[] {
return (
Array.isArray(value) && value.every((item) => typeof item === "string")
);
}
function isStringRecord(value: unknown): value is Record<string, string> {
return (
isPlainObject(value) &&
Object.values(value).every((item) => typeof item === "string")
);
}
+48 -1
View File
@@ -6,6 +6,14 @@ import {
createQuestionWaitingNotification, createQuestionWaitingNotification,
notifyQuestionWaiting, notifyQuestionWaiting,
} from "../notifications.ts"; } from "../notifications.ts";
import {
applyRemoteAskResponse,
type RemoteAskFlowHandle,
type RemoteAskResponse,
type RemoteAskRuntime,
type RemoteAskSource,
type RemoteAskSubmitResolution,
} from "../remote-ask.ts";
import { createInitialState } from "../state/create.ts"; import { createInitialState } from "../state/create.ts";
import { import {
getEditorDraft, getEditorDraft,
@@ -60,6 +68,11 @@ type Done = (result: AskResult) => void;
interface AskFlowOptions { interface AskFlowOptions {
allowFreeform?: boolean; allowFreeform?: boolean;
presentSingleAsMulti?: boolean; presentSingleAsMulti?: boolean;
remote?: {
runtime: RemoteAskRuntime;
source: RemoteAskSource;
toolCallId?: string;
};
} }
type AskFlowParams = AskParams & type AskFlowParams = AskParams &
@@ -79,6 +92,7 @@ interface AskFlowController {
editor: Editor; editor: Editor;
pendingQuestionTypeChangeQuestionId?: string; pendingQuestionTypeChangeQuestionId?: string;
pendingReviewShortcutActionIndex?: number; pendingReviewShortcutActionIndex?: number;
remoteFlow?: RemoteAskFlowHandle;
settingsOpen: boolean; settingsOpen: boolean;
state: AskState; state: AskState;
suppressAutoInputForSelection: boolean; suppressAutoInputForSelection: boolean;
@@ -151,6 +165,7 @@ function createAskFlowController(
}); });
controller.editor.onSubmit = (value) => submitEditor(controller, value); controller.editor.onSubmit = (value) => submitEditor(controller, value);
controller.remoteFlow = startRemoteFlow(controller, params);
syncSelection(controller); syncSelection(controller);
notifyCurrentQuestion(controller).catch(() => { notifyCurrentQuestion(controller).catch(() => {
// Notification failures are best-effort and must not affect the ask flow. // Notification failures are best-effort and must not affect the ask flow.
@@ -165,6 +180,7 @@ function createAskFlowController(
handleControllerInput(controller, data); handleControllerInput(controller, data);
}, },
dispose() { dispose() {
controller.remoteFlow?.dispose();
controller.unsubscribeConfig(); controller.unsubscribeConfig();
}, },
}; };
@@ -545,10 +561,41 @@ async function notifyCurrentQuestion(
function maybeFinish(controller: AskFlowController) { function maybeFinish(controller: AskFlowController) {
if (controller.state.completed) { if (controller.state.completed) {
controller.done(toAskResult(controller.state)); const result = toAskResult(controller.state);
controller.remoteFlow?.complete(result);
controller.done(result);
} }
} }
function startRemoteFlow(
controller: AskFlowController,
params: AskFlowParams
): RemoteAskFlowHandle | undefined {
const remote = params.flowOptions.remote;
if (!remote) {
return;
}
return remote.runtime.startFlow({
source: remote.source,
toolCallId: remote.toolCallId,
title: controller.state.title,
questions: controller.state.questions,
onSubmit: (response) => submitRemoteResponse(controller, response),
});
}
function submitRemoteResponse(
controller: AskFlowController,
response: RemoteAskResponse
): RemoteAskSubmitResolution {
const resolution = applyRemoteAskResponse(controller.state, response);
if (!resolution.ok) {
return resolution;
}
commitState(controller, resolution.state, { finish: true });
return { ok: true };
}
function hydrateEditor(controller: AskFlowController) { function hydrateEditor(controller: AskFlowController) {
controller.editor.setText(getEditorDraft(controller.state)); controller.editor.setText(getEditorDraft(controller.state));
} }
+272
View File
@@ -0,0 +1,272 @@
import assert from "node:assert/strict";
import test from "node:test";
import { DEFAULT_ASK_CONFIG } from "../src/config/defaults.ts";
import { getAskConfigStore } from "../src/config/store.ts";
import {
applyRemoteAskResponse,
createRemoteAskRuntime,
PI_ASK_COMPLETED_EVENT,
PI_ASK_STARTED_EVENT,
PI_ASK_SUBMIT_EVENT,
PI_ASK_SUBMIT_RESULT_EVENT,
type RemoteAskCompletedEvent,
type RemoteAskStartedEvent,
type RemoteAskSubmitResultEvent,
} from "../src/remote-ask.ts";
import { createInitialState } from "../src/state/create.ts";
import { runAskFlow } from "../src/ui/controller.ts";
const UNKNOWN_APPROVE_VALUE_RE = /Unknown option value "approve"/;
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
)
);
};
}
}
test("remote answer response validates and serializes explicit option values", () => {
const state = createInitialState({
questions: [
{
id: "decision",
prompt: "Proceed?",
options: [
{ value: "yes", label: "Yes" },
{ value: "no", label: "No" },
],
},
],
});
const result = applyRemoteAskResponse(state, {
kind: "answer",
answers: {
decision: { values: ["yes"], note: "ship it" },
},
});
assert.equal(result.ok, true);
assert.equal(result.state.completed, true);
assert.deepEqual(result.state.answers.decision, {
selected: [{ value: "yes", label: "Yes", index: 1 }],
note: "ship it",
customSelected: undefined,
customText: undefined,
optionNotes: undefined,
});
});
test("remote answer response replaces stale UI answers", () => {
const state = createInitialState({
questions: [
{
id: "decision",
prompt: "Proceed?",
options: [{ value: "yes", label: "Yes" }],
},
{
id: "stale",
prompt: "Old answer?",
options: [{ value: "old", label: "Old" }],
},
],
});
state.answers.stale = {
selected: [{ value: "old", label: "Old", index: 1 }],
};
const result = applyRemoteAskResponse(state, {
kind: "answer",
answers: { decision: { values: ["yes"] } },
});
assert.equal(result.ok, true);
assert.deepEqual(Object.keys(result.state.answers), ["decision"]);
});
test("remote answer response rejects guessed or unknown option values", () => {
const state = createInitialState({
questions: [
{
id: "decision",
prompt: "Proceed?",
options: [{ value: "yes", label: "Yes" }],
},
],
});
const result = applyRemoteAskResponse(state, {
kind: "answer",
answers: { decision: { values: ["approve"] } },
});
assert.equal(result.ok, false);
assert.equal(result.error, "invalid_answer");
assert.match(result.message, UNKNOWN_APPROVE_VALUE_RE);
assert.equal(result.state.completed, false);
});
test("remote cancel completes the flow as cancelled", () => {
const state = createInitialState({
questions: [
{
id: "decision",
prompt: "Proceed?",
options: [{ value: "yes", label: "Yes" }],
},
],
});
const result = applyRemoteAskResponse(state, { kind: "cancel" });
assert.equal(result.ok, true);
assert.equal(result.state.completed, true);
assert.equal(result.state.cancelled, true);
});
test("started event questions are cloned", async () => {
const bus = new TestEventBus();
const remoteAsk = createRemoteAskRuntime(bus as never);
const questions = createInitialState({
questions: [
{
id: "decision",
prompt: "Proceed?",
options: [{ value: "yes", label: "Yes" }],
},
],
}).questions;
remoteAsk.startFlow({
source: "tool",
questions,
onSubmit: () => ({ ok: true }),
});
await new Promise((resolve) => setImmediate(resolve));
const started = bus.events.find(
(event) => event.channel === PI_ASK_STARTED_EVENT
)?.data as RemoteAskStartedEvent;
started.questions[0].options[0].label = "Mutated";
assert.equal(questions[0].options[0].label, "Yes");
});
test("remote submit resolves an active ask flow and emits lifecycle events", async () => {
getAskConfigStore().setConfig({
...DEFAULT_ASK_CONFIG,
notifications: { ...DEFAULT_ASK_CONFIG.notifications, enabled: false },
});
const bus = new TestEventBus();
const remoteAsk = createRemoteAskRuntime(bus as never);
let component: { handleInput(data: string): void } | undefined;
bus.on(PI_ASK_STARTED_EVENT, (data) => {
const event = data as RemoteAskStartedEvent;
bus.emit(PI_ASK_SUBMIT_EVENT, {
version: 1,
requestId: "request-1",
flowId: event.flowId,
response: {
kind: "answer",
answers: { decision: { values: ["yes"] } },
},
});
});
const result = await runAskFlow(
{
cwd: process.cwd(),
ui: {
custom(callback: (...args: unknown[]) => unknown) {
return new Promise((resolve) => {
component = callback(
{
requestRender() {
// Rendering is not needed for this controller input test.
},
},
plainTheme(),
{},
resolve
) as typeof component;
});
},
},
} as never,
{
questions: [
{
id: "decision",
prompt: "Proceed?",
options: [
{ value: "yes", label: "Yes" },
{ value: "no", label: "No" },
],
},
],
},
{ remote: { runtime: remoteAsk, source: "tool", toolCallId: "call-1" } }
);
assert(component);
assert.deepEqual(result.answers.decision, {
values: ["yes"],
labels: ["Yes"],
indices: [1],
customText: undefined,
note: undefined,
optionNotes: undefined,
});
const started = bus.events.find(
(event) => event.channel === PI_ASK_STARTED_EVENT
)?.data as RemoteAskStartedEvent;
assert.equal(started.flowId, "tool:call-1");
assert.equal(started.source, "tool");
assert.equal(started.questions[0]?.id, "decision");
const submitResult = bus.events.find(
(event) => event.channel === PI_ASK_SUBMIT_RESULT_EVENT
)?.data as RemoteAskSubmitResultEvent;
assert.equal(submitResult.ok, true);
const completed = bus.events.find(
(event) => event.channel === PI_ASK_COMPLETED_EVENT
)?.data as RemoteAskCompletedEvent;
assert.equal(completed.flowId, "tool:call-1");
assert.equal(completed.result.cancelled, false);
getAskConfigStore().setConfig(DEFAULT_ASK_CONFIG);
});
function plainTheme() {
return {
bg(_color: string, text: string) {
return text;
},
fg(_color: string, text: string) {
return text;
},
};
}