@@ -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