@@ -3,7 +3,7 @@
|
||||
# @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)
|
||||
|
||||
`@eko24ive/pi-ask` is an ask tool that cares about your answers.
|
||||
|
||||
+3
-1
@@ -5,7 +5,8 @@ This folder keeps only the documentation needed to understand and maintain the e
|
||||
## Files
|
||||
|
||||
- `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
|
||||
- `../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
|
||||
- read `contract.md` for behavior
|
||||
- read `remote-events.md` for local bridge/event integration
|
||||
- read `architecture.md` for code layout
|
||||
|
||||
## Local test run
|
||||
|
||||
@@ -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/ask-payload-store.ts` — branch-aware persisted ask payload lookup
|
||||
- `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/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
|
||||
- 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
|
||||
- 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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
## 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
|
||||
|
||||
- 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`
|
||||
|
||||
@@ -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.
|
||||
@@ -24,6 +24,8 @@
|
||||
"src/",
|
||||
"skills/",
|
||||
"docs/configuration.md",
|
||||
"docs/contract.md",
|
||||
"docs/remote-events.md",
|
||||
"README.md",
|
||||
"LICENSE",
|
||||
"CHANGELOG.md"
|
||||
|
||||
+29
-5
@@ -19,6 +19,7 @@ import {
|
||||
validateParams,
|
||||
} from "./ask-tool-helpers.ts";
|
||||
import { getAskConfigStore } from "./config/store.ts";
|
||||
import type { RemoteAskRuntime, RemoteAskSource } from "./remote-ask.ts";
|
||||
import type { AskParams, AskResult } from "./types.ts";
|
||||
import { runAskFlow } from "./ui/controller.ts";
|
||||
|
||||
@@ -37,11 +38,14 @@ type ExtractionUiResult =
|
||||
| { error: string }
|
||||
| { params: AskParams };
|
||||
|
||||
export function registerAnswerCommands(pi: ExtensionAPI): void {
|
||||
export function registerAnswerCommands(
|
||||
pi: ExtensionAPI,
|
||||
remoteAsk?: RemoteAskRuntime
|
||||
): void {
|
||||
pi.registerCommand("answer", {
|
||||
description:
|
||||
"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", {
|
||||
@@ -51,7 +55,9 @@ export function registerAnswerCommands(pi: ExtensionAPI): void {
|
||||
missingMessage:
|
||||
"No previous /answer form found on this branch; use /answer first.",
|
||||
noticePrefix: "Reopening previous /answer form on this branch",
|
||||
remoteSource: "answer:again",
|
||||
source: "answer-extraction",
|
||||
remoteAsk,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -61,14 +67,17 @@ export function registerAnswerCommands(pi: ExtensionAPI): void {
|
||||
runReplayCommand(pi, ctx, {
|
||||
missingMessage: "No previous ask_user form found on this branch.",
|
||||
noticePrefix: "Replaying previous ask_user form on this branch",
|
||||
remoteSource: "ask:replay",
|
||||
source: "tool",
|
||||
remoteAsk,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async function runAnswerCommand(
|
||||
pi: ExtensionAPI,
|
||||
ctx: ExtensionCommandContext
|
||||
ctx: ExtensionCommandContext,
|
||||
remoteAsk?: RemoteAskRuntime
|
||||
): Promise<void> {
|
||||
if (!ctx.hasUI) {
|
||||
ctx.ui.notify("/answer requires interactive UI.", "error");
|
||||
@@ -115,6 +124,8 @@ async function runAnswerCommand(
|
||||
|
||||
await runAskAndSendSubmittedResult(pi, ctx, params, {
|
||||
allowFreeform: true,
|
||||
remoteAsk,
|
||||
remoteSource: "answer",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -237,6 +248,8 @@ async function runReplayCommand(
|
||||
options: {
|
||||
missingMessage: string;
|
||||
noticePrefix: string;
|
||||
remoteAsk?: RemoteAskRuntime;
|
||||
remoteSource: RemoteAskSource;
|
||||
source: AskPayloadSource;
|
||||
}
|
||||
): Promise<void> {
|
||||
@@ -260,6 +273,8 @@ async function runReplayCommand(
|
||||
);
|
||||
await runAskAndSendSubmittedResult(pi, ctx, lookup.data.params, {
|
||||
allowFreeform: options.source === "answer-extraction",
|
||||
remoteAsk: options.remoteAsk,
|
||||
remoteSource: options.remoteSource,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -267,10 +282,19 @@ async function runAskAndSendSubmittedResult(
|
||||
pi: Pick<ExtensionAPI, "sendUserMessage">,
|
||||
ctx: ExtensionContext,
|
||||
params: AskParams,
|
||||
options: { allowFreeform: boolean }
|
||||
options: {
|
||||
allowFreeform: boolean;
|
||||
remoteAsk?: RemoteAskRuntime;
|
||||
remoteSource: RemoteAskSource;
|
||||
}
|
||||
): Promise<void> {
|
||||
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) {
|
||||
ctx.ui.notify("Ask form cancelled.", "info");
|
||||
|
||||
+14
-4
@@ -14,11 +14,15 @@ import {
|
||||
validateParams,
|
||||
} from "./ask-tool-helpers.ts";
|
||||
import { getAskConfigStore } from "./config/store.ts";
|
||||
import type { RemoteAskRuntime } from "./remote-ask.ts";
|
||||
import { AskParamsSchema } from "./schema.ts";
|
||||
import type { AskParams } from "./types.ts";
|
||||
import { runAskFlow } from "./ui/controller.ts";
|
||||
|
||||
export function registerAskTool(pi: ExtensionAPI) {
|
||||
export function registerAskTool(
|
||||
pi: ExtensionAPI,
|
||||
remoteAsk?: RemoteAskRuntime
|
||||
) {
|
||||
pi.registerTool({
|
||||
name: "ask_user",
|
||||
label: "Ask User",
|
||||
@@ -34,7 +38,8 @@ export function registerAskTool(pi: ExtensionAPI) {
|
||||
params as AskParams,
|
||||
signal,
|
||||
onUpdate,
|
||||
ctx
|
||||
ctx,
|
||||
remoteAsk
|
||||
),
|
||||
renderCall: renderAskToolCall,
|
||||
renderResult: renderAskToolResult,
|
||||
@@ -47,7 +52,8 @@ async function executeAskTool(
|
||||
params: AskParams,
|
||||
_signal: AbortSignal | undefined,
|
||||
_onUpdate: unknown,
|
||||
ctx: ExtensionContext
|
||||
ctx: ExtensionContext,
|
||||
remoteAsk?: RemoteAskRuntime
|
||||
) {
|
||||
const config = await getAskConfigStore().getConfig();
|
||||
const validation = validateParams(params, {
|
||||
@@ -66,7 +72,11 @@ async function executeAskTool(
|
||||
}
|
||||
ctx.ui.setWorkingVisible(false);
|
||||
try {
|
||||
const result = await runAskFlow(ctx, params);
|
||||
const result = await runAskFlow(ctx, params, {
|
||||
remote: remoteAsk
|
||||
? { runtime: remoteAsk, source: "tool", toolCallId }
|
||||
: undefined,
|
||||
});
|
||||
return successfulResponse(result);
|
||||
} finally {
|
||||
ctx.ui.setWorkingVisible(true);
|
||||
|
||||
+4
-2
@@ -4,6 +4,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import { registerAnswerCommands } from "./answer-commands.ts";
|
||||
import { registerAskSettingsCommand } from "./ask-settings-command.ts";
|
||||
import { registerAskTool } from "./ask-tool.ts";
|
||||
import { createRemoteAskRuntime } from "./remote-ask.ts";
|
||||
|
||||
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const CONFIGURATION_DOC_PATH = resolve(
|
||||
@@ -17,7 +18,8 @@ export default function askExtension(pi: ExtensionAPI) {
|
||||
pi.on("before_agent_start", async (event) => ({
|
||||
systemPrompt: `${event.systemPrompt}\n\n${PI_ASK_CONFIG_PROMPT}`,
|
||||
}));
|
||||
registerAskTool(pi);
|
||||
const remoteAsk = createRemoteAskRuntime(pi.events);
|
||||
registerAskTool(pi, remoteAsk);
|
||||
registerAskSettingsCommand(pi);
|
||||
registerAnswerCommands(pi);
|
||||
registerAnswerCommands(pi, remoteAsk);
|
||||
}
|
||||
|
||||
@@ -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
@@ -6,6 +6,14 @@ import {
|
||||
createQuestionWaitingNotification,
|
||||
notifyQuestionWaiting,
|
||||
} 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 {
|
||||
getEditorDraft,
|
||||
@@ -60,6 +68,11 @@ type Done = (result: AskResult) => void;
|
||||
interface AskFlowOptions {
|
||||
allowFreeform?: boolean;
|
||||
presentSingleAsMulti?: boolean;
|
||||
remote?: {
|
||||
runtime: RemoteAskRuntime;
|
||||
source: RemoteAskSource;
|
||||
toolCallId?: string;
|
||||
};
|
||||
}
|
||||
|
||||
type AskFlowParams = AskParams &
|
||||
@@ -79,6 +92,7 @@ interface AskFlowController {
|
||||
editor: Editor;
|
||||
pendingQuestionTypeChangeQuestionId?: string;
|
||||
pendingReviewShortcutActionIndex?: number;
|
||||
remoteFlow?: RemoteAskFlowHandle;
|
||||
settingsOpen: boolean;
|
||||
state: AskState;
|
||||
suppressAutoInputForSelection: boolean;
|
||||
@@ -151,6 +165,7 @@ function createAskFlowController(
|
||||
});
|
||||
|
||||
controller.editor.onSubmit = (value) => submitEditor(controller, value);
|
||||
controller.remoteFlow = startRemoteFlow(controller, params);
|
||||
syncSelection(controller);
|
||||
notifyCurrentQuestion(controller).catch(() => {
|
||||
// Notification failures are best-effort and must not affect the ask flow.
|
||||
@@ -165,6 +180,7 @@ function createAskFlowController(
|
||||
handleControllerInput(controller, data);
|
||||
},
|
||||
dispose() {
|
||||
controller.remoteFlow?.dispose();
|
||||
controller.unsubscribeConfig();
|
||||
},
|
||||
};
|
||||
@@ -545,10 +561,41 @@ async function notifyCurrentQuestion(
|
||||
|
||||
function maybeFinish(controller: AskFlowController) {
|
||||
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) {
|
||||
controller.editor.setText(getEditorDraft(controller.state));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user