@black942026/pi-ask-user 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +389 -2
- package/index.ts +2 -0
- package/package.json +62 -4
- package/src/adapters/pi.ts +239 -0
- package/src/answers.ts +84 -0
- package/src/ask.ts +103 -0
- package/src/config.ts +392 -0
- package/src/core.ts +322 -0
- package/src/deadline.ts +94 -0
- package/src/events.ts +96 -0
- package/src/index.ts +358 -0
- package/src/mode.ts +18 -0
- package/src/parse.ts +119 -0
- package/src/route.ts +144 -0
- package/src/schema.ts +255 -0
- package/src/types.ts +345 -0
- package/src/ui/custom.ts +908 -0
- package/src/ui/native.ts +112 -0
package/src/core.ts
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import { combineSignals, createDeadline, safeTimeoutMs } from "./deadline.ts";
|
|
2
|
+
import { AskUserValidationError, normalizeAskUserRequest } from "./schema.ts";
|
|
3
|
+
import type {
|
|
4
|
+
AskUIInput,
|
|
5
|
+
AskUIOutcome,
|
|
6
|
+
AskUserResult,
|
|
7
|
+
AskUserSupport,
|
|
8
|
+
AskUserOptions,
|
|
9
|
+
AskUserUiPreferences,
|
|
10
|
+
NormalizedRequest,
|
|
11
|
+
} from "./types.ts";
|
|
12
|
+
|
|
13
|
+
function messageOf(error: unknown): string {
|
|
14
|
+
return error instanceof Error ? error.message : String(error);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Apply the host's user-configured UI preferences over a normalized request.
|
|
19
|
+
*
|
|
20
|
+
* Precedence, per knob: explicit user preference > request value > built-in
|
|
21
|
+
* default (the latter already baked into the normalized request by the schema).
|
|
22
|
+
* The timeout is recomputed from the effective per-question value so the single
|
|
23
|
+
* shared deadline stays `timeoutPerQuestionMs × questionCount`.
|
|
24
|
+
*/
|
|
25
|
+
function applyPreferences(request: NormalizedRequest, preferences: AskUserUiPreferences | undefined): NormalizedRequest {
|
|
26
|
+
if (!preferences) return request;
|
|
27
|
+
const displayMode = preferences.displayMode ?? request.displayMode;
|
|
28
|
+
const timeoutPerQuestionMs = preferences.timeoutPerQuestionMs ?? request.timeoutPerQuestionMs;
|
|
29
|
+
if (displayMode === request.displayMode && timeoutPerQuestionMs === request.timeoutPerQuestionMs) return request;
|
|
30
|
+
return {
|
|
31
|
+
...request,
|
|
32
|
+
displayMode,
|
|
33
|
+
timeoutPerQuestionMs,
|
|
34
|
+
totalTimeoutMs: timeoutPerQuestionMs * request.questions.length,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The single reusable entry point. Accepts raw (usually model-produced) input,
|
|
40
|
+
* normalizes it, then runs it through the route the host resolved at creation.
|
|
41
|
+
*
|
|
42
|
+
* Import this from another extension to reuse the interaction without going
|
|
43
|
+
* through the model:
|
|
44
|
+
*
|
|
45
|
+
* ```ts
|
|
46
|
+
* const result = await askUser({ questions: [...] }, { host: createPiHost(ctx) });
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
export async function askUser(raw: unknown, options: AskUserOptions): Promise<AskUserResult> {
|
|
50
|
+
let request: NormalizedRequest;
|
|
51
|
+
let warnings: string[] = [];
|
|
52
|
+
try {
|
|
53
|
+
const normalized = normalizeAskUserRequest(raw);
|
|
54
|
+
request = normalized.request;
|
|
55
|
+
warnings = normalized.warnings;
|
|
56
|
+
} catch (error) {
|
|
57
|
+
const message =
|
|
58
|
+
error instanceof AskUserValidationError ? error.message : `Invalid request: ${messageOf(error)}`;
|
|
59
|
+
return {
|
|
60
|
+
status: "error",
|
|
61
|
+
answers: [],
|
|
62
|
+
error: { code: "invalid_request", message },
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
return askUserNormalized(request, options, warnings);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Like {@link askUser}, but for an already-normalized request. */
|
|
69
|
+
export async function askUserNormalized(
|
|
70
|
+
request: NormalizedRequest,
|
|
71
|
+
options: AskUserOptions,
|
|
72
|
+
warnings: string[] = [],
|
|
73
|
+
): Promise<AskUserResult> {
|
|
74
|
+
const { host, signal, onUpdate, now, events } = options;
|
|
75
|
+
// The route was resolved once, when the host was created; the core never
|
|
76
|
+
// re-selects it. A host that cannot prompt here is refused before any
|
|
77
|
+
// rendering, and never in favour of a different route.
|
|
78
|
+
const support = host.support;
|
|
79
|
+
if (support.status !== "available") {
|
|
80
|
+
return finish(supportErrorResult(support), warnings);
|
|
81
|
+
}
|
|
82
|
+
const route = support.route;
|
|
83
|
+
// Cancellation is checked before any route branch, so an already-aborted call
|
|
84
|
+
// never renders an interactive UI.
|
|
85
|
+
if (signal?.aborted) {
|
|
86
|
+
return finish({ status: "aborted", route, answers: [], cancelledReason: "abort" }, warnings);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// User preferences (explicit option over the user config file) win over the
|
|
90
|
+
// request's own displayMode/timeout; the request wins over the built-in
|
|
91
|
+
// defaults already applied by the schema.
|
|
92
|
+
const effectiveRequest = applyPreferences(request, host.preferences);
|
|
93
|
+
const deadline = createDeadline(effectiveRequest.totalTimeoutMs, now);
|
|
94
|
+
const deadlineController = new AbortController();
|
|
95
|
+
const timeoutCancelled = new AbortController();
|
|
96
|
+
const linked = combineSignals(signal, deadlineController.signal);
|
|
97
|
+
|
|
98
|
+
const input: AskUIInput = { request: effectiveRequest, deadline, signal: linked.signal };
|
|
99
|
+
if (route === "custom") {
|
|
100
|
+
input.cancelTimeout = () => {
|
|
101
|
+
if (!deadline.cancel()) return false;
|
|
102
|
+
timeoutCancelled.abort();
|
|
103
|
+
return true;
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
if (onUpdate) input.onUpdate = onUpdate;
|
|
107
|
+
|
|
108
|
+
// Wait events are emitted exactly around a real UI attempt: `waitStarted`
|
|
109
|
+
// immediately before the renderer is invoked, and `waitEnded` once on the
|
|
110
|
+
// single settle path. Requests refused before this point emit nothing.
|
|
111
|
+
let waitStarted = false;
|
|
112
|
+
const notifyStart = (): void => {
|
|
113
|
+
if (!events) return;
|
|
114
|
+
try {
|
|
115
|
+
events.waitStarted();
|
|
116
|
+
} catch {
|
|
117
|
+
// An observer must never break the interaction.
|
|
118
|
+
}
|
|
119
|
+
waitStarted = true;
|
|
120
|
+
};
|
|
121
|
+
const settle = (result: AskUserResult): AskUserResult => {
|
|
122
|
+
const finalized = finish(result, warnings);
|
|
123
|
+
if (waitStarted && events) {
|
|
124
|
+
try {
|
|
125
|
+
events.waitEnded(finalized);
|
|
126
|
+
} catch {
|
|
127
|
+
// An observer must never break the interaction.
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return finalized;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
let renderPromise: Promise<AskUIOutcome>;
|
|
134
|
+
notifyStart();
|
|
135
|
+
try {
|
|
136
|
+
if (route === "custom") {
|
|
137
|
+
if (!host.customUI) throw new Error("customUI capability declared without a renderer");
|
|
138
|
+
renderPromise = host.customUI.render(input);
|
|
139
|
+
} else {
|
|
140
|
+
if (!host.nativeDialogs) throw new Error("nativeDialogs capability declared without a runner");
|
|
141
|
+
renderPromise = host.nativeDialogs.run(input);
|
|
142
|
+
}
|
|
143
|
+
} catch (error) {
|
|
144
|
+
linked.dispose();
|
|
145
|
+
return settle(mapOutcome({ kind: "error", message: messageOf(error) }, route, effectiveRequest));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// The core owns the guarantee: even a renderer that ignores its AbortSignal
|
|
149
|
+
// cannot hang the call unless the user explicitly disables the timeout.
|
|
150
|
+
// The deadline and caller's cancellation are raced independently against
|
|
151
|
+
// the renderer; a late renderer result is ignored.
|
|
152
|
+
let race: DeadlineRace;
|
|
153
|
+
try {
|
|
154
|
+
race = await raceWithDeadline(
|
|
155
|
+
renderPromise, deadline.remainingMs(), signal,
|
|
156
|
+
() => deadlineController.abort(), timeoutCancelled.signal,
|
|
157
|
+
);
|
|
158
|
+
} finally {
|
|
159
|
+
// Always release the listeners installed on the caller's signal.
|
|
160
|
+
linked.dispose();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (race.kind === "timeout") {
|
|
164
|
+
deadlineController.abort();
|
|
165
|
+
return settle({ status: "timeout", route, answers: [] });
|
|
166
|
+
}
|
|
167
|
+
if (race.kind === "abort") {
|
|
168
|
+
deadlineController.abort();
|
|
169
|
+
return settle({ status: "aborted", route, answers: [], cancelledReason: "abort" });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
let outcome = race.outcome;
|
|
173
|
+
// A dismissal that coincides with the shared deadline is a timeout.
|
|
174
|
+
if ((outcome.kind === "cancelled" || outcome.kind === "abort") && deadline.expired()) {
|
|
175
|
+
outcome = { kind: "timeout" };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return settle(mapOutcome(outcome, route, effectiveRequest));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
type DeadlineRace =
|
|
182
|
+
| { kind: "outcome"; outcome: AskUIOutcome }
|
|
183
|
+
| { kind: "timeout" }
|
|
184
|
+
| { kind: "abort" };
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Race a renderer against the deadline and the caller's abort signal.
|
|
188
|
+
*
|
|
189
|
+
* This is what makes the timeout guarantee independent of renderer behaviour: a
|
|
190
|
+
* renderer that never resolves (or that ignores its signal) still yields
|
|
191
|
+
* `timeout`, unless the user explicitly cancels the timeout. Late results are
|
|
192
|
+
* discarded, and the rejection path is always
|
|
193
|
+
* handled so a late failure cannot become an unhandled rejection.
|
|
194
|
+
*/
|
|
195
|
+
function raceWithDeadline(
|
|
196
|
+
render: Promise<AskUIOutcome>,
|
|
197
|
+
remainingMs: number,
|
|
198
|
+
callerSignal: AbortSignal | undefined,
|
|
199
|
+
onDeadline: () => void,
|
|
200
|
+
timeoutCancelled: AbortSignal,
|
|
201
|
+
): Promise<DeadlineRace> {
|
|
202
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
203
|
+
let abortHandler: (() => void) | undefined;
|
|
204
|
+
const clearDeadlineTimer = (): void => {
|
|
205
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
206
|
+
timer = undefined;
|
|
207
|
+
};
|
|
208
|
+
const racers: Array<Promise<DeadlineRace>> = [
|
|
209
|
+
render.then(
|
|
210
|
+
(outcome): DeadlineRace => ({ kind: "outcome", outcome }),
|
|
211
|
+
(error): DeadlineRace => ({ kind: "outcome", outcome: { kind: "error", message: messageOf(error) } }),
|
|
212
|
+
),
|
|
213
|
+
new Promise<DeadlineRace>((resolve) => {
|
|
214
|
+
// Cancellation may happen synchronously during renderer construction.
|
|
215
|
+
if (timeoutCancelled.aborted) return;
|
|
216
|
+
timeoutCancelled.addEventListener("abort", clearDeadlineTimer, { once: true });
|
|
217
|
+
// `safeTimeoutMs` prevents a >32-bit delay from firing immediately.
|
|
218
|
+
timer = setTimeout(() => {
|
|
219
|
+
onDeadline();
|
|
220
|
+
resolve({ kind: "timeout" });
|
|
221
|
+
}, safeTimeoutMs(remainingMs));
|
|
222
|
+
}),
|
|
223
|
+
];
|
|
224
|
+
if (callerSignal) {
|
|
225
|
+
racers.push(
|
|
226
|
+
new Promise<DeadlineRace>((resolve) => {
|
|
227
|
+
if (callerSignal.aborted) {
|
|
228
|
+
resolve({ kind: "abort" });
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
abortHandler = () => resolve({ kind: "abort" });
|
|
232
|
+
callerSignal.addEventListener("abort", abortHandler, { once: true });
|
|
233
|
+
}),
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
return Promise.race(racers).finally(() => {
|
|
237
|
+
clearDeadlineTimer();
|
|
238
|
+
timeoutCancelled.removeEventListener("abort", clearDeadlineTimer);
|
|
239
|
+
// Remove the listener when the render settled first, so repeated calls do
|
|
240
|
+
// not accumulate listeners on a long-lived caller signal.
|
|
241
|
+
if (abortHandler && callerSignal) callerSignal.removeEventListener("abort", abortHandler);
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Turn a non-`available` support status into the matching error result.
|
|
247
|
+
*
|
|
248
|
+
* Nothing is rendered and no question is asked. An explicitly configured route
|
|
249
|
+
* that cannot run is reported as `unsupported_mode` and names that requested
|
|
250
|
+
* route; an environment with no usable UI reports `unsupported_mode` with no
|
|
251
|
+
* route at all, so no default is ever fabricated.
|
|
252
|
+
*/
|
|
253
|
+
function supportErrorResult(support: Exclude<AskUserSupport, { status: "available" }>): AskUserResult {
|
|
254
|
+
if (support.status === "invalid_config") {
|
|
255
|
+
return { status: "error", answers: [], error: { code: "invalid_config", message: support.reason } };
|
|
256
|
+
}
|
|
257
|
+
if (support.status === "configured_unavailable") {
|
|
258
|
+
return {
|
|
259
|
+
status: "error",
|
|
260
|
+
route: support.configured,
|
|
261
|
+
answers: [],
|
|
262
|
+
error: { code: "unsupported_mode", message: support.reason },
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
return { status: "error", answers: [], error: { code: "unsupported_mode", message: support.reason } };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function mapOutcome(
|
|
269
|
+
outcome: AskUIOutcome,
|
|
270
|
+
route: "custom" | "native",
|
|
271
|
+
request: NormalizedRequest,
|
|
272
|
+
): AskUserResult {
|
|
273
|
+
switch (outcome.kind) {
|
|
274
|
+
case "submitted": {
|
|
275
|
+
const answers = outcome.answers ?? [];
|
|
276
|
+
if (answers.length !== request.questions.length) {
|
|
277
|
+
return {
|
|
278
|
+
status: "error",
|
|
279
|
+
route,
|
|
280
|
+
answers,
|
|
281
|
+
error: {
|
|
282
|
+
code: "internal",
|
|
283
|
+
message: `${route} UI returned ${answers.length} answers, expected ${request.questions.length}.`,
|
|
284
|
+
},
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
const empty = answers.find(
|
|
288
|
+
(answer) => answer.selections.length === 0 && (answer.freeText ?? "").trim() === "",
|
|
289
|
+
);
|
|
290
|
+
if (empty) {
|
|
291
|
+
return {
|
|
292
|
+
status: "error",
|
|
293
|
+
route,
|
|
294
|
+
answers,
|
|
295
|
+
error: { code: "empty_answer", message: `The answer to question "${empty.title}" is empty.` },
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
return { status: "answered", route, answers };
|
|
299
|
+
}
|
|
300
|
+
case "cancelled":
|
|
301
|
+
return { status: "aborted", route, answers: [], cancelledReason: "user" };
|
|
302
|
+
case "abort":
|
|
303
|
+
return { status: "aborted", route, answers: [], cancelledReason: "abort" };
|
|
304
|
+
case "timeout":
|
|
305
|
+
return { status: "timeout", route, answers: [] };
|
|
306
|
+
case "error":
|
|
307
|
+
return {
|
|
308
|
+
status: "error",
|
|
309
|
+
route,
|
|
310
|
+
answers: [],
|
|
311
|
+
error: {
|
|
312
|
+
code: route === "custom" ? "custom_ui_failed" : "native_ui_failed",
|
|
313
|
+
message: outcome.message ?? `${route} UI failed.`,
|
|
314
|
+
},
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function finish(result: AskUserResult, warnings: string[]): AskUserResult {
|
|
320
|
+
if (warnings.length > 0) result.warnings = warnings;
|
|
321
|
+
return result;
|
|
322
|
+
}
|
package/src/deadline.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { Deadline } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Largest delay Node's `setTimeout` accepts (a 32-bit signed integer). Larger
|
|
5
|
+
* values are clamped by Node to ~1 ms, which would fire a timer immediately and
|
|
6
|
+
* turn a huge timeout into an instant one. We clamp instead.
|
|
7
|
+
*/
|
|
8
|
+
export const MAX_SAFE_TIMEOUT_MS = 2_147_483_647;
|
|
9
|
+
|
|
10
|
+
/** Clamp a requested timeout to a value `setTimeout` can honour. */
|
|
11
|
+
export function safeTimeoutMs(ms: number): number {
|
|
12
|
+
if (!Number.isFinite(ms) || ms <= 0) return 0;
|
|
13
|
+
return Math.min(Math.floor(ms), MAX_SAFE_TIMEOUT_MS);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** A shared deadline that the user can disable for the current questionnaire. */
|
|
17
|
+
export interface CancellableDeadline extends Deadline {
|
|
18
|
+
/** Disable expiry. Returns false if the deadline has already expired. */
|
|
19
|
+
cancel(): boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Create a single overall deadline for a questionnaire.
|
|
24
|
+
*
|
|
25
|
+
* The initial deadline is `totalMs` (which callers compute as
|
|
26
|
+
* `timeoutPerQuestionMs × questionCount`). It is clamped to
|
|
27
|
+
* {@link MAX_SAFE_TIMEOUT_MS} so `setTimeout` never overflows. Every interaction
|
|
28
|
+
* for the questionnaire shares this one deadline; native dialogs receive only
|
|
29
|
+
* the remaining time. `cancel()` disables expiry but does not clear any timers;
|
|
30
|
+
* the core and custom UI clear their timers separately. A deadline never
|
|
31
|
+
* degrades to another route.
|
|
32
|
+
*/
|
|
33
|
+
export function createDeadline(totalMs: number, now: () => number = Date.now): CancellableDeadline {
|
|
34
|
+
const startedAt = now();
|
|
35
|
+
const safeTotal = safeTimeoutMs(totalMs);
|
|
36
|
+
const expiresAt = safeTotal === 0 ? startedAt : startedAt + safeTotal;
|
|
37
|
+
let cancelled = false;
|
|
38
|
+
return {
|
|
39
|
+
startedAt,
|
|
40
|
+
get expiresAt(): number {
|
|
41
|
+
return cancelled ? Infinity : expiresAt;
|
|
42
|
+
},
|
|
43
|
+
remainingMs(): number {
|
|
44
|
+
return cancelled ? Infinity : Math.max(0, expiresAt - now());
|
|
45
|
+
},
|
|
46
|
+
expired(): boolean {
|
|
47
|
+
return !cancelled && now() >= expiresAt;
|
|
48
|
+
},
|
|
49
|
+
cancel(): boolean {
|
|
50
|
+
if (!cancelled && now() >= expiresAt) return false;
|
|
51
|
+
cancelled = true;
|
|
52
|
+
return true;
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A signal plus the cleanup for the listeners it installed on its sources. */
|
|
58
|
+
export interface LinkedSignal {
|
|
59
|
+
signal: AbortSignal;
|
|
60
|
+
/** Remove listeners from the source signals. Idempotent. */
|
|
61
|
+
dispose(): void;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Combine the caller's abort signal with a deadline-driven abort signal.
|
|
66
|
+
*
|
|
67
|
+
* Unlike `AbortSignal.any`, this returns an explicit `dispose()` so listeners
|
|
68
|
+
* installed on the caller's signal are removed once the interaction settles and
|
|
69
|
+
* do not accumulate across repeated calls.
|
|
70
|
+
*/
|
|
71
|
+
export function combineSignals(caller: AbortSignal | undefined, deadlineSignal: AbortSignal): LinkedSignal {
|
|
72
|
+
const controller = new AbortController();
|
|
73
|
+
const onDeadline = () => controller.abort(deadlineSignal.reason);
|
|
74
|
+
const onCaller = () => controller.abort(caller?.reason);
|
|
75
|
+
let disposed = false;
|
|
76
|
+
|
|
77
|
+
if (deadlineSignal.aborted) controller.abort(deadlineSignal.reason);
|
|
78
|
+
else deadlineSignal.addEventListener("abort", onDeadline, { once: true });
|
|
79
|
+
|
|
80
|
+
if (caller) {
|
|
81
|
+
if (caller.aborted) controller.abort(caller.reason);
|
|
82
|
+
else caller.addEventListener("abort", onCaller, { once: true });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
signal: controller.signal,
|
|
87
|
+
dispose() {
|
|
88
|
+
if (disposed) return;
|
|
89
|
+
disposed = true;
|
|
90
|
+
deadlineSignal.removeEventListener("abort", onDeadline);
|
|
91
|
+
if (caller) caller.removeEventListener("abort", onCaller);
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
package/src/events.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime event emission for the registered `ask_user` tool.
|
|
3
|
+
*
|
|
4
|
+
* These events carry *result semantics* that Pi's generic `ui_prompt_start` /
|
|
5
|
+
* `ui_prompt_end` do not: whether the wait ended with an answer, a dismissal, a
|
|
6
|
+
* timeout, or an error — and through which route.
|
|
7
|
+
*
|
|
8
|
+
* Two families are emitted:
|
|
9
|
+
*
|
|
10
|
+
* 1. `herdr:blocked` — `{ active: true, label, callId? }` only when the tool is
|
|
11
|
+
* actually entering the interactive wait, and exactly one matching
|
|
12
|
+
* `{ active: false, callId? }` afterwards, for every outcome (answered,
|
|
13
|
+
* aborted, timeout, error). Invalid or unavailable requests never emit it,
|
|
14
|
+
* because no UI is attempted.
|
|
15
|
+
* 2. `ask:answered` / `ask:aborted` / `ask:timeout` / `ask:error` — exactly once
|
|
16
|
+
* per UI attempt, carrying the correlation id and the route/status. By
|
|
17
|
+
* default the full question, answers, and free text are **never** broadcast;
|
|
18
|
+
* only non-sensitive metadata leaves the extension.
|
|
19
|
+
*
|
|
20
|
+
* A direct {@link createAskUser} caller has no Pi event bus; it may inject an
|
|
21
|
+
* {@link AskUserEventSink} instead (or accept the deliberate absence). This
|
|
22
|
+
* module never fabricates access to a bus.
|
|
23
|
+
*/
|
|
24
|
+
import type { AskUserEventSink, AskUserResult, AskUserRoute } from "./types.ts";
|
|
25
|
+
|
|
26
|
+
/** The minimal event-bus surface used here (structurally matches Pi's EventBus). */
|
|
27
|
+
export interface AskEventBus {
|
|
28
|
+
emit(channel: string, data: unknown): void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const HERDR_BLOCKED = "herdr:blocked";
|
|
32
|
+
export const ASK_ANSWERED = "ask:answered";
|
|
33
|
+
export const ASK_ABORTED = "ask:aborted";
|
|
34
|
+
export const ASK_TIMEOUT = "ask:timeout";
|
|
35
|
+
export const ASK_ERROR = "ask:error";
|
|
36
|
+
|
|
37
|
+
/** Fixed label for the blocked/idle indicator. */
|
|
38
|
+
export const WAITING_LABEL = "Waiting for user response";
|
|
39
|
+
|
|
40
|
+
function optionalCallId(callId: string | undefined): { callId?: string } {
|
|
41
|
+
return callId === undefined || callId === "" ? {} : { callId };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Build the Pi event-bus sink for one tool call. Returns `undefined` when no
|
|
46
|
+
* usable bus is present, so the caller emits nothing rather than faking it.
|
|
47
|
+
* Emission failures are swallowed: events must never break the interaction.
|
|
48
|
+
*/
|
|
49
|
+
export function createAskEventSink(bus: AskEventBus | undefined, callId?: string): AskUserEventSink | undefined {
|
|
50
|
+
if (!bus || typeof bus.emit !== "function") return undefined;
|
|
51
|
+
const emit = (channel: string, data: unknown): void => {
|
|
52
|
+
try {
|
|
53
|
+
bus.emit(channel, data);
|
|
54
|
+
} catch {
|
|
55
|
+
// An observer must never break the interaction.
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
return {
|
|
59
|
+
waitStarted(): void {
|
|
60
|
+
emit(HERDR_BLOCKED, { active: true, label: WAITING_LABEL, ...optionalCallId(callId) });
|
|
61
|
+
},
|
|
62
|
+
waitEnded(result: AskUserResult): void {
|
|
63
|
+
emit(HERDR_BLOCKED, { active: false, ...optionalCallId(callId) });
|
|
64
|
+
const correlation: { callId?: string; route?: AskUserRoute } = { ...optionalCallId(callId) };
|
|
65
|
+
if (result.route !== undefined) correlation.route = result.route;
|
|
66
|
+
switch (result.status) {
|
|
67
|
+
case "answered":
|
|
68
|
+
emit(ASK_ANSWERED, { ...correlation, status: "answered" });
|
|
69
|
+
break;
|
|
70
|
+
case "aborted":
|
|
71
|
+
emit(ASK_ABORTED, { ...correlation, status: "aborted", cancelledReason: result.cancelledReason ?? "user" });
|
|
72
|
+
break;
|
|
73
|
+
case "timeout":
|
|
74
|
+
emit(ASK_TIMEOUT, { ...correlation, status: "timeout" });
|
|
75
|
+
break;
|
|
76
|
+
case "error":
|
|
77
|
+
emit(ASK_ERROR, { ...correlation, status: "error", errorCode: result.error?.code });
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The outcome-event channel for a status, exposed for documentation/tests. */
|
|
85
|
+
export function outcomeChannel(status: AskUserResult["status"]): string {
|
|
86
|
+
switch (status) {
|
|
87
|
+
case "answered":
|
|
88
|
+
return ASK_ANSWERED;
|
|
89
|
+
case "aborted":
|
|
90
|
+
return ASK_ABORTED;
|
|
91
|
+
case "timeout":
|
|
92
|
+
return ASK_TIMEOUT;
|
|
93
|
+
case "error":
|
|
94
|
+
return ASK_ERROR;
|
|
95
|
+
}
|
|
96
|
+
}
|