omp-conductor 0.14.0 → 0.15.0

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.
@@ -0,0 +1,249 @@
1
+ /**
2
+ * The presentation surface the setup wizard ({@link setup-wizard.ts}) codes
3
+ * against. Nothing about it is terminal-specific: any driver that satisfies this
4
+ * exact shape can drive the wizard, and today that driver is the terminal
5
+ * `omp-conductor setup` verb (#309 deleted the in-session dialog).
6
+ *
7
+ * Three of the four methods are prompt-bearing. The load-bearing contracts,
8
+ * each of which the wizard already relies on and a future refactor would break
9
+ * silently:
10
+ *
11
+ * - `input` resolves the placeholder on an empty submit ("Enter accepts what
12
+ * you see"), so a re-run that Enters through a prompt re-affirms the shown
13
+ * default instead of blanking it.
14
+ * - `select` resolves the chosen option's **label**, never its index — the
15
+ * label *is* the config value for the closed-vocabulary questions, so a
16
+ * lookup table that disagreed with the vocabulary it was built from would
17
+ * corrupt the written config.
18
+ * - `select` honours `dialogOptions.initialIndex` so a bare Enter re-affirms
19
+ * the current setting (see the "cannot be a confirm" note in the wizard).
20
+ * - `confirm` is `y/N`, defaulting to no.
21
+ *
22
+ * Every prompt resolves `undefined` when the operator dismisses it (Ctrl-C /
23
+ * EOF), which every wizard caller already treats as the cancel path
24
+ * (`Cancelled`). The terminal UI guarantees the readline interface is closed on
25
+ * every exit path so the verb cannot hang the terminal.
26
+ */
27
+ export interface WizardUi {
28
+ notify(message: string, type?: "info" | "warning" | "error"): void;
29
+ /**
30
+ * Yes/no. `undefined` means the operator *dismissed* the prompt rather than
31
+ * answering it, which is a different thing from "no": the wizard turns it into
32
+ * `Cancelled` and abandons the run. Collapsing the two is how Ctrl-C at a
33
+ * confirm used to record a silent "no" and carry on to the next question.
34
+ */
35
+ confirm(title: string, message: string): Promise<boolean | undefined>;
36
+ /** Single-line text prompt. `undefined` dismisses it; an empty submit accepts
37
+ * the placeholder. */
38
+ input(title: string, placeholder?: string): Promise<string | undefined>;
39
+ /** Numbered single-choice list. Resolves the chosen option's label, or
40
+ * `undefined` when dismissed. */
41
+ select(
42
+ title: string,
43
+ options: { label: string; description?: string }[],
44
+ dialogOptions?: { initialIndex?: number },
45
+ ): Promise<string | undefined>;
46
+ }
47
+
48
+ import { createInterface, type Interface } from "node:readline/promises";
49
+ import { stdin, stdout } from "node:process";
50
+ import type { Readable, Writable } from "node:stream";
51
+
52
+ /**
53
+ * A `WizardUi` over a real terminal — or a pipe, which is how the verb is
54
+ * scripted in tests — backed by `node:readline/promises`.
55
+ *
56
+ * **One interface for the whole wizard, closed once.** The first version of this
57
+ * created and closed a readline interface per prompt, on the theory that
58
+ * releasing stdin after each question could not leak. It cannot: closing a
59
+ * readline interface ends the shared stdin stream, so the *second* prompt of a
60
+ * piped run had no readable input left and the run hung indefinitely — the exact
61
+ * failure this comment used to claim it prevented. The interface therefore lives
62
+ * as long as the wizard, and {@link TerminalUi.close} releases it once, from the
63
+ * caller's `finally`.
64
+ *
65
+ * Dismissal is the interface's own `close`: `rl.question()` on an exhausted
66
+ * stream never settles on its own, so EOF (a script that ran out of answers) and
67
+ * Ctrl-C both close the interface, and the line reader turns that into the
68
+ * `undefined` every wizard caller reads as cancel.
69
+ *
70
+ * `io` exists so the contracts below can be tested in-process against a pipe
71
+ * rather than only through a spawned CLI; production callers pass nothing.
72
+ */
73
+ export interface TerminalUi extends WizardUi {
74
+ /** Releases stdin. Idempotent; call it from a `finally`. */
75
+ close(): void;
76
+ }
77
+
78
+ export function terminalUi(io: { input?: Readable; output?: Writable } = {}): TerminalUi {
79
+ const input = io.input ?? stdin;
80
+ const output = io.output ?? stdout;
81
+ const rl: Interface = createInterface({ input, output });
82
+ let closed = false;
83
+ const close = (): void => {
84
+ if (closed) return;
85
+ closed = true;
86
+ rl.close();
87
+ };
88
+ // Ctrl-C is a cancel, not a signal to the whole process: closing the interface
89
+ // makes every pending and subsequent prompt resolve `undefined`, which the
90
+ // wizard already turns into `Cancelled`.
91
+ rl.once("SIGINT", close);
92
+
93
+ // Lines are buffered as they arrive rather than read on demand, because on a
94
+ // non-TTY stdin readline drains the whole pipe at once and emits `line` for
95
+ // every one of them immediately. `rl.question()` only captures the line that
96
+ // arrives *after* it is called, so a scripted run lost every answer that had
97
+ // already flown past and then hit EOF — three prompts resolving `undefined`
98
+ // and a wizard that cancelled itself. Buffering makes a pipe and a terminal
99
+ // behave the same: nothing is read before it is asked for, nothing is lost.
100
+ const buffered: string[] = [];
101
+ let waiting: ((value: string | undefined) => void) | undefined;
102
+ let ended = false;
103
+ const deliver = (value: string | undefined): boolean => {
104
+ const resolve = waiting;
105
+ if (resolve === undefined) return false;
106
+ waiting = undefined;
107
+ resolve(value);
108
+ return true;
109
+ };
110
+ rl.on("line", (text: string) => {
111
+ if (!deliver(text)) buffered.push(text);
112
+ });
113
+ rl.once("close", () => {
114
+ ended = true;
115
+ deliver(undefined);
116
+ });
117
+
118
+ /** One line, or `undefined` when the operator dismissed the prompt. */
119
+ const line = async (query: string): Promise<string | undefined> => {
120
+ if (closed && buffered.length === 0) return undefined;
121
+ output.write(query);
122
+ const ready = buffered.shift();
123
+ if (ready !== undefined) return ready;
124
+ if (ended) return undefined;
125
+ return await new Promise<string | undefined>((resolve) => {
126
+ waiting = resolve;
127
+ });
128
+ };
129
+
130
+ return {
131
+ close,
132
+ notify(message) {
133
+ output.write(`${message}\n`);
134
+ },
135
+ async confirm(title, message) {
136
+ for (;;) {
137
+ const answer = await line(`${title} — ${message} (y/N) `);
138
+ // Dismissal is NOT "no". Ctrl-C or EOF here means the operator left, and
139
+ // reporting `false` made the interview march on to the next question with
140
+ // a silent "no" recorded — so `Ctrl-C abandons the run` was untrue for
141
+ // every yes/no prompt. The wizard turns this into `Cancelled`.
142
+ if (answer === undefined) return undefined;
143
+ const a = answer.trim().toLowerCase();
144
+ if (a === "y" || a === "yes") return true;
145
+ // Enter is still the safe answer: no, and the interview continues.
146
+ if (a === "n" || a === "no" || a === "") return false;
147
+ output.write("Please answer y or n.\n");
148
+ }
149
+ },
150
+ async input(title, placeholder) {
151
+ const query =
152
+ placeholder !== undefined && placeholder.length > 0 ? `${title} [${placeholder}]: ` : `${title}: `;
153
+ const answer = await line(query);
154
+ if (answer === undefined) return undefined;
155
+ // "Enter accepts what you see": an empty submit keeps the placeholder.
156
+ return answer.trim().length === 0 ? (placeholder ?? "") : answer;
157
+ },
158
+ async select(title, options, dialogOptions) {
159
+ output.write(`${title}\n`);
160
+ options.forEach((o, i) => {
161
+ output.write(` ${i + 1}. ${o.label}${o.description !== undefined ? ` — ${o.description}` : ""}\n`);
162
+ });
163
+ const initial = dialogOptions?.initialIndex ?? 0;
164
+ const defaultLabel = options[initial]?.label ?? "";
165
+ for (;;) {
166
+ const answer = await line(
167
+ `Select 1-${options.length} [${initial + 1} = ${defaultLabel}] (Enter keeps it, Ctrl-C cancels): `,
168
+ );
169
+ if (answer === undefined) return undefined;
170
+ const a = answer.trim();
171
+ if (a === "") return defaultLabel; // a bare Enter re-affirms the current row
172
+ const n = Number(a);
173
+ if (Number.isInteger(n)) {
174
+ const picked = options[n - 1];
175
+ if (picked !== undefined) return picked.label;
176
+ }
177
+ output.write(`Please enter a number between 1 and ${options.length}.\n`);
178
+ }
179
+ },
180
+ };
181
+ }
182
+
183
+ /**
184
+ * The test seam: a `WizardUi` driven by a script keyed by a substring of each
185
+ * prompt's title.
186
+ *
187
+ * **Anything unscripted takes the answer an operator gets by pressing Enter** —
188
+ * an empty `input` (which the wizard reads as "accept the shown default"), `no`
189
+ * on a `confirm`, and the row the caller put the cursor on for a `select`. That
190
+ * leniency is deliberate and is itself the thing under test: "Enter accepts what
191
+ * you see" is the contract the whole wizard is built on, so a test that has to
192
+ * script an answer is a test whose default moved. Throwing on an unscripted
193
+ * prompt would make that property untestable.
194
+ *
195
+ * Asking an *unintended* question is caught a stronger way instead: the suite
196
+ * pins the full ordered list of prompts, so a question that appears or vanishes
197
+ * fails on the transcript rather than on a missing script entry.
198
+ *
199
+ * - `input`: the scripted string, else `""` (Enter → the wizard's fallback).
200
+ * - `confirm`: the scripted boolean, else `false`. `null` dismisses the prompt
201
+ * (`undefined`), which the wizard reads as cancel — the same spelling
202
+ * `select` uses.
203
+ * - `select`: a number picks that option's **label**; a string matches an
204
+ * offered label by prefix; `null` dismisses the prompt (`undefined`, which
205
+ * the wizard reads as cancel); unscripted takes `initialIndex`'s label.
206
+ * - An array answers successive prompts sharing the same title; the last entry
207
+ * repeats.
208
+ */
209
+ export interface ScriptedAnswers {
210
+ input?: Record<string, string>;
211
+ confirm?: Record<string, boolean | boolean[] | null>;
212
+ select?: Record<string, string | number | null>;
213
+ }
214
+
215
+ export function scriptedUi(script: ScriptedAnswers = {}): WizardUi {
216
+ const seen = new Map<string, number>();
217
+ const answer = <T>(table: Record<string, T> | undefined, title: string): T | undefined => {
218
+ for (const [key, value] of Object.entries(table ?? {})) {
219
+ if (!title.includes(key)) continue;
220
+ if (!Array.isArray(value)) return value;
221
+ const n = seen.get(key) ?? 0;
222
+ seen.set(key, n + 1);
223
+ return (value[Math.min(n, value.length - 1)] ?? value.at(-1)) as T;
224
+ }
225
+ return undefined;
226
+ };
227
+
228
+ return {
229
+ notify() {
230
+ /* recorded by the caller's wrapper when a test asserts on notices */
231
+ },
232
+ async confirm(title) {
233
+ const scripted = answer(script.confirm, title);
234
+ if (scripted === null) return undefined;
235
+ return Array.isArray(scripted) ? false : (scripted ?? false);
236
+ },
237
+ async input(title) {
238
+ return answer(script.input, title) ?? "";
239
+ },
240
+ async select(title, options, dialogOptions) {
241
+ const chosen = answer(script.select, title);
242
+ if (chosen === null) return undefined;
243
+ if (typeof chosen === "number") return options[chosen]?.label;
244
+ if (typeof chosen === "string") return options.find((o) => o.label.startsWith(chosen))?.label ?? chosen;
245
+ // Enter: the row the caller put the cursor on.
246
+ return options[dialogOptions?.initialIndex ?? 0]?.label;
247
+ },
248
+ };
249
+ }
package/src/worker.ts CHANGED
@@ -609,7 +609,12 @@ function field(source: unknown, key: string): unknown {
609
609
  }
610
610
 
611
611
  /** Flatten an assistant message's content blocks to their plain text. */
612
- function reportText(content: unknown): string {
612
+ /**
613
+ * The newest assistant text, flattened out of whatever block shape the harness
614
+ * sent. Exported for ./setup-probe.ts, which collects a probe's answer the same
615
+ * way a worker's report is collected — one spelling, so the two cannot drift.
616
+ */
617
+ export function reportText(content: unknown): string {
613
618
  if (typeof content === "string") return content.trim();
614
619
  const blocks: readonly unknown[] = Array.isArray(content) ? content : [];
615
620
  const parts: string[] = [];