@pi-archimedes/ask 2.10.3 → 2.11.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-archimedes/ask",
3
- "version": "2.10.3",
3
+ "version": "2.11.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/danielcherubini/pi-archimedes.git"
@@ -15,16 +15,16 @@
15
15
  ],
16
16
  "main": "./src/index.ts",
17
17
  "dependencies": {
18
- "@pi-archimedes/core": "2.10.3"
18
+ "@pi-archimedes/core": "2.11.0"
19
19
  },
20
20
  "peerDependencies": {
21
- "@earendil-works/pi-coding-agent": ">=0.1.0",
22
- "@earendil-works/pi-tui": ">=0.1.0",
21
+ "@earendil-works/pi-coding-agent": ">=1.2.0",
22
+ "@earendil-works/pi-tui": ">=1.2.0",
23
23
  "typebox": ">=1.1.0"
24
24
  },
25
25
  "devDependencies": {
26
- "@earendil-works/pi-coding-agent": "^0.87.0",
27
- "@earendil-works/pi-tui": "^0.87.0",
26
+ "@earendil-works/pi-coding-agent": "1.2.0",
27
+ "@earendil-works/pi-tui": "1.2.0",
28
28
  "typebox": "^1.1.38",
29
29
  "typescript": "^6.0.0"
30
30
  },
@@ -7,12 +7,17 @@ describe("ask default export (standalone factory)", () => {
7
7
 
8
8
  const on = vi.fn();
9
9
  const registerTool = vi.fn();
10
- const pi = { on, registerTool } as any;
10
+ // `events` is the host handle the relay subscribes its ASK_REQUEST
11
+ // listener on — required on every pi this package declares support for.
12
+ const subscribe = vi.fn(() => () => {});
13
+ const pi = { on, registerTool, events: { emit: vi.fn(), on: subscribe } } as any;
11
14
 
12
15
  defaultExport(pi);
13
16
 
14
17
  // ask tool registered
15
18
  expect(registerTool).toHaveBeenCalled();
19
+ // the subagent ask relay subscribed on the host handle
20
+ expect(subscribe).toHaveBeenCalledWith("archimedes:ask_request", expect.any(Function));
16
21
 
17
22
  // registerAsk wires its own session lifecycle internally
18
23
  const events = on.mock.calls.map((c: Array<unknown>) => c[0]);
package/src/ipc-relay.ts CHANGED
@@ -1,5 +1,5 @@
1
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import { getBus, Events } from "@pi-archimedes/core/bus";
1
+ import type { EventBus, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { Events, emitOn } from "@pi-archimedes/core/events";
3
3
  import { getBridge } from "@pi-archimedes/core/bridge";
4
4
  import { type AskQuestion, type AskSelection } from "./selection.js";
5
5
  import { askSingleQuestionWithInlineNote } from "./picker.js";
@@ -11,7 +11,7 @@ export function registerIpcRelay(
11
11
  getCtx: () => ExtensionContext | undefined,
12
12
  unsubscribes: Array<() => void>,
13
13
  ): void {
14
- const unsubAskRequest = getBus().on(Events.ASK_REQUEST, async (payload: unknown) => {
14
+ const unsubAskRequest = pi.events.on(Events.ASK_REQUEST, async (payload: unknown) => {
15
15
  const data = payload as {
16
16
  source: string;
17
17
  requestId: string;
@@ -32,7 +32,7 @@ export function registerIpcRelay(
32
32
 
33
33
  // Defer to next tick so TUI can process current state
34
34
  await new Promise((resolve) => setImmediate(resolve));
35
- await handleAskRequest(data, getCtx, (active, label) => emitBlocked(pi, active, label));
35
+ await handleAskRequest(data, getCtx, (active, label) => emitBlocked(pi, active, label), pi.events);
36
36
  });
37
37
  unsubscribes.push(unsubAskRequest);
38
38
  }
@@ -44,6 +44,7 @@ async function handleAskRequest(
44
44
  },
45
45
  getCtx: () => ExtensionContext | undefined,
46
46
  reportBlocked: (active: boolean, label?: string) => void,
47
+ events: EventBus,
47
48
  ): Promise<void> {
48
49
  const ctx = getCtx();
49
50
  if (!ctx?.ui) return;
@@ -96,8 +97,8 @@ async function handleAskRequest(
96
97
  customInput: selections[i]?.customInput,
97
98
  }));
98
99
 
99
- // Emit on bus — parent's streamEvents writes to child socket
100
- getBus().emit(Events.ASK_RESPONSE, {
100
+ // Emit on the handle — the parent's streamEvents writes it to the child socket
101
+ emitOn(events, Events.ASK_RESPONSE, {
101
102
  requestId: data.requestId,
102
103
  cancelled,
103
104
  results,
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Task 11 item 4 — the inline note's caret is pi-tui's fake cursor, not a
3
+ * hand-rolled reverse video.
4
+ *
5
+ * What pi-tui does with each shape (read from `dist/tui.js`):
6
+ *
7
+ * renderFakeCursor(text) = FAKE_CURSOR_START + text + FAKE_CURSOR_END
8
+ * = "\x1b_pi:fc\x07" … "\x1b_pi:/fc\x07"
9
+ *
10
+ * Both are zero-width APC sequences. `TUI.resolveFakeCursors()` turns them into
11
+ * `\x1b[7m`/`\x1b[27m` when it draws the frame — and, when
12
+ * `showHardwareCursor` is on, it first drops a fake cursor that sits directly
13
+ * after `CURSOR_MARKER` (`FOCUSED_FAKE_CURSOR = /(\x1b_pi:c\x07)\x1b_pi:fc\x07(.*?)(?:\x1b_pi:\/fc\x07|$)/s`,
14
+ * replaced with `$1$2`), because the terminal is already drawing a cursor there.
15
+ *
16
+ * That is why the raw escapes were wrong: `\x1b[7m` is not inside fake-cursor
17
+ * markers, so `resolveFakeCursors` cannot see it and the frame carried the
18
+ * inverted block *and* the hardware cursor — two cursors. `CURSOR_MARKER` on its
19
+ * own was still emitted, so `extractCursorPosition()` still positioned the real
20
+ * cursor; only the block was redundant.
21
+ *
22
+ * The `CURSOR_MARKER` half is unchanged and still conditional
23
+ * (`includeHardwareCursorMarker`), and it must stay immediately before the fake
24
+ * cursor for that drop to fire. Issue #100 (`CURSOR_MARKER` lost when a prompt
25
+ * row is truncated) is a separate, still-open defect about the truncation paths;
26
+ * nothing here fixes or worsens it — the width assertions below pin that.
27
+ */
28
+
29
+ import { describe, it, expect } from "vitest";
30
+ import { CURSOR_MARKER, renderFakeCursor, visibleWidth } from "@earendil-works/pi-tui";
31
+ import {
32
+ INLINE_NOTE_WRAP_PADDING,
33
+ buildOptionLabelWithInlineNote,
34
+ buildWrappedOptionLabelWithInlineNote,
35
+ } from "./note.js";
36
+
37
+ /**
38
+ * pi-tui exports the writer (`renderFakeCursor`) but not the class that resolves
39
+ * the markers, so the frame pass is only reachable through its dist file. A
40
+ * test-only import — nothing in this package's runtime touches it. Running pi-tui
41
+ * rather than re-typing its pattern is the point: the claim under test is what
42
+ * the frame does with the cell this package emits, and a copied regex would only
43
+ * prove the test agrees with itself.
44
+ */
45
+ const { TuiBase } = (await import("@earendil-works/pi-tui/dist/tui.js")) as unknown as {
46
+ TuiBase: { prototype: { resolveFakeCursors(this: { showHardwareCursor: boolean }, lines: string[]): string[] } };
47
+ };
48
+
49
+ /** What pi-tui's own frame pass makes of a line, for each `showHardwareCursor` setting. */
50
+ function resolve(line: string, showHardwareCursor: boolean): string {
51
+ return TuiBase.prototype.resolveFakeCursors.call({ showHardwareCursor }, [line])[0]!;
52
+ }
53
+
54
+ const occurrences = (haystack: string, needle: string) => haystack.split(needle).length - 1;
55
+
56
+ /** pi-tui's own markers, derived from the exported helper rather than re-typed. */
57
+ const fakeCursorCell = renderFakeCursor("x");
58
+ const FAKE_CURSOR_START = fakeCursorCell.slice(0, fakeCursorCell.indexOf("x"));
59
+ const FAKE_CURSOR_END = fakeCursorCell.slice(fakeCursorCell.indexOf("x") + 1);
60
+
61
+ const SEP = " — note: ";
62
+
63
+ describe("editing inline note caret", () => {
64
+ it("wraps the caret character in pi-tui's fake cursor, not raw reverse video", () => {
65
+ const label = buildOptionLabelWithInlineNote("Option", "ab", true, undefined, 1, true);
66
+ expect(label).toBe(`Option${SEP}a${CURSOR_MARKER}${renderFakeCursor("b")}`);
67
+ expect(label).not.toContain("\x1b[7m");
68
+ expect(label).not.toContain("\x1b[27m");
69
+ });
70
+
71
+ it("puts the hardware-cursor marker directly before the fake cursor, which is the adjacency resolveFakeCursors matches", () => {
72
+ const label = buildOptionLabelWithInlineNote("Option", "abc", true, undefined, 1, true);
73
+ expect(label).toContain(`${CURSOR_MARKER}${FAKE_CURSOR_START}b${FAKE_CURSOR_END}`);
74
+ });
75
+
76
+ it("drops the hardware-cursor marker when the caller does not want it, and keeps the fake cursor", () => {
77
+ const label = buildOptionLabelWithInlineNote("Option", "ab", true, undefined, 1, false);
78
+ expect(label).not.toContain(CURSOR_MARKER);
79
+ expect(label).toContain(`${FAKE_CURSOR_START}b${FAKE_CURSOR_END}`);
80
+ });
81
+
82
+ it("clamps the caret to the end of the note (unchanged behaviour)", () => {
83
+ const label = buildOptionLabelWithInlineNote("Option", "ab", true, undefined, 9, true);
84
+ expect(label).toBe(`Option${SEP}ab${CURSOR_MARKER}${renderFakeCursor(" ")}`);
85
+ });
86
+
87
+ it("draws a space for a caret past an empty note (unchanged behaviour)", () => {
88
+ const label = buildOptionLabelWithInlineNote("Option", "", true, undefined, 0, true);
89
+ expect(label).toBe(`Option${SEP}${CURSOR_MARKER}${renderFakeCursor(" ")}`);
90
+ });
91
+
92
+ it("splits before/after the caret across control characters rather than hiding them (unchanged behaviour)", () => {
93
+ const label = buildOptionLabelWithInlineNote("Option", "a\tb", true, undefined, 1, true);
94
+ expect(label).toBe(`Option${SEP}a${CURSOR_MARKER}${renderFakeCursor(" ")}b`);
95
+ });
96
+ });
97
+
98
+ describe("non-editing inline note", () => {
99
+ it("stays plain text — no cursor of either kind", () => {
100
+ const label = buildOptionLabelWithInlineNote("Option", " typed ", false);
101
+ expect(label).toBe(`Option${SEP}typed`);
102
+ expect(label).not.toContain(CURSOR_MARKER);
103
+ expect(label).not.toContain(FAKE_CURSOR_START);
104
+ });
105
+
106
+ it("adds nothing at all for an empty note on an option that is not being edited", () => {
107
+ expect(buildOptionLabelWithInlineNote("Option", " ", false)).toBe("Option");
108
+ });
109
+ });
110
+
111
+ describe("truncation and wrapping with the fake cursor", () => {
112
+ it("keeps the caret cell whole when the label fits, and counts the markers as zero width", () => {
113
+ const label = buildOptionLabelWithInlineNote("Option", "abc", true, 200, 1, true);
114
+ expect(label).toContain(`${CURSOR_MARKER}${FAKE_CURSOR_START}b${FAKE_CURSOR_END}`);
115
+ // "Option — note: abc" — 18 visible columns, marker bytes invisible.
116
+ expect(visibleWidth(label)).toBe(visibleWidth(`Option${SEP}abc`));
117
+ });
118
+
119
+ it("truncates from the head while editing, so the caret stays on screen", () => {
120
+ const long = "x".repeat(60);
121
+ const label = buildOptionLabelWithInlineNote("Option", long, true, 30, 59, true);
122
+ expect(label.startsWith("…")).toBe(true);
123
+ // The caret is at the very end of the note, so it survives the head cut.
124
+ expect(label).toContain(`${FAKE_CURSOR_START}x${FAKE_CURSOR_END}`);
125
+ });
126
+
127
+ it("wraps to the requested width and keeps exactly one caret cell across the lines", () => {
128
+ const long = "note text that is long enough to need wrapping here";
129
+ const lines = buildWrappedOptionLabelWithInlineNote(
130
+ "Option",
131
+ long,
132
+ true,
133
+ 40,
134
+ INLINE_NOTE_WRAP_PADDING,
135
+ 20,
136
+ true,
137
+ );
138
+ expect(lines.length).toBeGreaterThan(1);
139
+ for (const line of lines) expect(visibleWidth(line)).toBeLessThanOrEqual(40);
140
+ const joined = lines.join("\n");
141
+ expect(joined.split(`${FAKE_CURSOR_START}`).length - 1).toBe(1);
142
+ expect(joined).toContain(CURSOR_MARKER);
143
+ });
144
+ });
145
+
146
+ // The measurement the change rests on, executed rather than read: what pi-tui's
147
+ // frame pass leaves behind for the cell this package emits.
148
+ describe("caret through pi-tui's resolveFakeCursors", () => {
149
+ const INVERT_ON = "\x1b[7m";
150
+ const INVERT_OFF = "\x1b[27m";
151
+ const caret = buildOptionLabelWithInlineNote("Option", "ab", true, undefined, 1, true);
152
+
153
+ it("with no hardware cursor, the cell becomes reverse video", () => {
154
+ const resolved = resolve(caret, false);
155
+ expect(resolved).toContain(`${INVERT_ON}b${INVERT_OFF}`);
156
+ // The zero-width markers are gone, so they cannot leak into the frame.
157
+ expect(resolved).not.toContain(FAKE_CURSOR_START);
158
+ expect(resolved).not.toContain(FAKE_CURSOR_END);
159
+ });
160
+
161
+ it("with the hardware cursor on, the focused cell drops its block — exactly one cursor", () => {
162
+ const resolved = resolve(caret, true);
163
+ expect(occurrences(resolved, INVERT_ON)).toBe(0);
164
+ // The hardware marker itself survives: it is what positions the real cursor.
165
+ expect(resolved).toContain(CURSOR_MARKER);
166
+ });
167
+
168
+ it("the shape this package used to emit keeps two cursors when the hardware one is on", () => {
169
+ // The old caret, built the way `note.ts` built it before the fix: a raw
170
+ // reverse-video cell after CURSOR_MARKER. pi-tui cannot see it, so the drop
171
+ // above does not happen and the frame carries both.
172
+ const oldCaret = `Option${SEP}a${CURSOR_MARKER}${INVERT_ON}b${INVERT_OFF}`;
173
+ const resolved = resolve(oldCaret, true);
174
+ expect(resolved).toBe(oldCaret);
175
+ expect(occurrences(resolved, INVERT_ON)).toBe(1);
176
+ });
177
+
178
+ it("a caret with no hardware marker keeps its block in both settings (an unfocused fake cursor)", () => {
179
+ const noMarker = buildOptionLabelWithInlineNote("Option", "ab", true, undefined, 1, false);
180
+ for (const showHardwareCursor of [false, true]) {
181
+ const resolved = resolve(noMarker, showHardwareCursor);
182
+ expect(resolved).toContain(`${INVERT_ON}b${INVERT_OFF}`);
183
+ }
184
+ });
185
+
186
+ it("a plain option label is untouched by the pass", () => {
187
+ const plain = buildOptionLabelWithInlineNote("Option", "typed", false);
188
+ expect(resolve(plain, false)).toBe(plain);
189
+ expect(resolve(plain, true)).toBe(plain);
190
+ });
191
+ });
package/src/note.ts CHANGED
@@ -1,8 +1,6 @@
1
- import { CURSOR_MARKER, wrapTextWithAnsi } from "@earendil-works/pi-tui";
1
+ import { CURSOR_MARKER, renderFakeCursor, wrapTextWithAnsi } from "@earendil-works/pi-tui";
2
2
 
3
3
  const INLINE_NOTE_SEPARATOR = " — note: ";
4
- const INLINE_EDIT_CURSOR_INVERT_ON = "\x1b[7m";
5
- const INLINE_EDIT_CURSOR_INVERT_OFF = "\x1b[27m";
6
4
 
7
5
  export const INLINE_NOTE_WRAP_PADDING = 2;
8
6
 
@@ -29,7 +27,15 @@ function buildEditingInlineNote(
29
27
  const afterCursorStartIndex = rawCharAtCursor.length > 0 ? cursorIndex + 1 : cursorIndex;
30
28
  const afterCursor = sanitizeNoteForInlineDisplay(rawNote.slice(afterCursorStartIndex));
31
29
  const cursorMarker = includeHardwareCursorMarker ? CURSOR_MARKER : "";
32
- const cursorCell = `${cursorMarker}${INLINE_EDIT_CURSOR_INVERT_ON}${charAtCursor}${INLINE_EDIT_CURSOR_INVERT_OFF}`;
30
+ // `renderFakeCursor()`, not a hand-rolled `\x1b[7m`: it wraps the cell in
31
+ // pi-tui's zero-width APC fake-cursor markers, which `TUI.resolveFakeCursors()`
32
+ // turns into reverse video when it draws the frame — and drops when
33
+ // `showHardwareCursor` is on and the cell sits directly after `CURSOR_MARKER`,
34
+ // because the terminal is drawing a cursor there already. Raw escapes are
35
+ // invisible to that pass, so the frame used to carry the inverted block *and*
36
+ // the hardware cursor. Keep the marker immediately before the cell: that
37
+ // adjacency is what the drop matches (`note.test.ts`).
38
+ const cursorCell = `${cursorMarker}${renderFakeCursor(charAtCursor)}`;
33
39
  return `${beforeCursor}${cursorCell}${afterCursor}`;
34
40
  }
35
41
 
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Task 11 item 3 — `ask` is for the model, not for scripts.
3
+ *
4
+ * `exposure: "model-only"` means: declared to the model while active, never
5
+ * callable through `ctx.executeTool()` (`pi: dist/core/extensions/types.d.ts`,
6
+ * `ToolExposure`; `dist/core/agent-session.js`, `_getCallableTools()` — only
7
+ * `direct` + active, `codemode` and `deferred` are callable). Codemode builds
8
+ * its callable set from exactly that list
9
+ * (`dist/extensions/codemode/execute.js`), so this costs something real, and it
10
+ * was measured on a real headless pi before shipping:
11
+ *
12
+ * before (`direct`) — a codemode script's `tools.ask({...})` resolved; it
13
+ * printed `ASK_TYPE: object {"cancelled":true,...}`.
14
+ * after (`model-only`) — the same script fails with
15
+ * `tools.ask does not exist. Available: ...` and `ask` is gone from its
16
+ * `ALL_TOOLS`.
17
+ *
18
+ * A probe tool carrying only `exposure: "model-only"` showed the same split on
19
+ * the same pi: the model called it and got its result, while a script's
20
+ * `tools.probe_model_only({})` reported it as non-existent. `ask` takes the
21
+ * exposure because a script that asks a question the user never sees (the TUI
22
+ * and bridge paths need a session's UI) orchestrates nothing it can answer for;
23
+ * the interactive tool belongs to the model's turn.
24
+ */
25
+
26
+ import { describe, it, expect, vi } from "vitest";
27
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
28
+ import { registerAskTool } from "./tool.js";
29
+
30
+ type RegisteredTool = { name: string; exposure?: string; annotations?: Record<string, boolean> };
31
+
32
+ function captureTool(): RegisteredTool {
33
+ const registered: RegisteredTool[] = [];
34
+ registerAskTool({
35
+ registerTool: (t: unknown) => registered.push(t as RegisteredTool),
36
+ events: { emit: vi.fn(), on: () => () => {} },
37
+ } as unknown as ExtensionAPI);
38
+ return registered[0]!;
39
+ }
40
+
41
+ describe("ask exposure", () => {
42
+ it("is model-only: declared to the model, never callable from a script", () => {
43
+ expect(captureTool().exposure).toBe("model-only");
44
+ });
45
+
46
+ it("carries no annotations — nothing here is a hint a permission gate needs", () => {
47
+ expect(captureTool().annotations).toBeUndefined();
48
+ });
49
+ });
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Task 11 item 2 — `ask` as a programmatic tool.
3
+ *
4
+ * `ask` has four success paths (bridge, headless socket, single-question TUI,
5
+ * tabs dialog) and they all build the same `QuestionResult[]`; only the text
6
+ * differs. The structured content is that answer set plus the one thing a
7
+ * script cannot otherwise tell from the text reliably: whether the user
8
+ * answered at all.
9
+ *
10
+ * As elsewhere in this task, the payload is produced by running the real
11
+ * handler and validated against the schema the registered tool declares. The
12
+ * TUI components are mocked (as `tool.test.ts` does) so the assertions are about
13
+ * the result contract, not about drawing a dialog. A crashed picker still
14
+ * throws — that is an unexpected failure, not a reported one (`tool.test.ts`
15
+ * pins it) — so nothing here converts it.
16
+ */
17
+
18
+ import { describe, it, expect, vi, beforeEach } from "vitest";
19
+ import { Value } from "typebox/value";
20
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
21
+ import { registerAskTool } from "./tool.js";
22
+
23
+ const { bridgeState, eventsState } = vi.hoisted(() => {
24
+ const ask = vi.fn();
25
+ return {
26
+ bridgeState: { active: false, ask, confirm: vi.fn(), password: vi.fn(), state: vi.fn(() => "idle") },
27
+ eventsState: { emit: vi.fn(), on: () => () => {} },
28
+ };
29
+ });
30
+
31
+ vi.mock("@pi-archimedes/core/bridge", () => ({ getBridge: () => bridgeState }));
32
+ vi.mock("./picker.js", () => ({ askSingleQuestionWithInlineNote: vi.fn() }));
33
+ vi.mock("./dialog.js", () => ({ askQuestionsWithTabs: vi.fn() }));
34
+
35
+ const { askSingleQuestionWithInlineNote } = await import("./picker.js");
36
+ const { askQuestionsWithTabs } = await import("./dialog.js");
37
+
38
+ type RegisteredTool = {
39
+ name: string;
40
+ outputSchema?: unknown;
41
+ execute: (
42
+ id: string,
43
+ params: unknown,
44
+ signal: unknown,
45
+ onUpdate: unknown,
46
+ ctx: unknown,
47
+ ) => Promise<{
48
+ content: Array<{ type: string; text: string }>;
49
+ details: unknown;
50
+ structuredContent?: unknown;
51
+ isError?: boolean;
52
+ }>;
53
+ };
54
+
55
+ function captureTool(): RegisteredTool {
56
+ const registered: RegisteredTool[] = [];
57
+ registerAskTool({ registerTool: (t: unknown) => registered.push(t as RegisteredTool), events: eventsState } as unknown as ExtensionAPI);
58
+ return registered[0]!;
59
+ }
60
+
61
+ function check(tool: RegisteredTool, value: unknown): boolean {
62
+ expect(tool.outputSchema, "ask declares no outputSchema").toBeDefined();
63
+ return Value.Check(tool.outputSchema as never, value);
64
+ }
65
+
66
+ const oneQuestion = {
67
+ questions: [
68
+ { id: "q1", question: "Which option?", description: "Some context", options: [{ label: "A" }, { label: "B" }] },
69
+ ],
70
+ };
71
+
72
+ const twoQuestions = {
73
+ questions: [
74
+ { id: "q1", question: "First?", options: [{ label: "A" }, { label: "B" }] },
75
+ { id: "q2", question: "Second?", multi: true, options: [{ label: "A" }, { label: "B" }] },
76
+ ],
77
+ };
78
+
79
+ beforeEach(() => {
80
+ vi.clearAllMocks();
81
+ bridgeState.active = false;
82
+ });
83
+
84
+ describe("ask outputSchema", () => {
85
+ it("declares one", () => {
86
+ expect(captureTool().outputSchema).toBeDefined();
87
+ });
88
+
89
+ it("the answer set is what the schema describes: results + cancelled", () => {
90
+ const tool = captureTool();
91
+ const props = (tool.outputSchema as { properties?: Record<string, unknown> }).properties ?? {};
92
+ expect(Object.keys(props).sort()).toEqual(["cancelled", "results"]);
93
+ });
94
+ });
95
+
96
+ describe("ask structuredContent — success paths", () => {
97
+ it("bridge answer → { cancelled: false, results: [one answered result] }", async () => {
98
+ bridgeState.active = true;
99
+ bridgeState.ask.mockResolvedValue({
100
+ cancelled: false,
101
+ results: [{ id: "q1", selectedOptions: ["B"], customInput: "extra" }],
102
+ });
103
+
104
+ const tool = captureTool();
105
+ const result = await tool.execute("t1", oneQuestion, undefined, undefined, { hasUI: true, ui: {} });
106
+
107
+ expect(result.structuredContent).toEqual({
108
+ cancelled: false,
109
+ results: [
110
+ {
111
+ id: "q1",
112
+ question: "Which option?",
113
+ description: "Some context",
114
+ options: ["A", "B"],
115
+ multi: false,
116
+ selectedOptions: ["B"],
117
+ customInput: "extra",
118
+ },
119
+ ],
120
+ });
121
+ expect(check(tool, result.structuredContent)).toBe(true);
122
+ // Content text unchanged — the summary line models read.
123
+ expect(result.content[0]!.text).toContain('q1: B + Other: "extra"');
124
+ });
125
+
126
+ it("bridge cancel → { cancelled: true, results: [one unanswered result] }, content text unchanged", async () => {
127
+ bridgeState.active = true;
128
+ bridgeState.ask.mockRejectedValue(new Error("bridge is not active"));
129
+
130
+ const tool = captureTool();
131
+ const result = await tool.execute("t1", oneQuestion, undefined, undefined, { hasUI: true, ui: {} });
132
+
133
+ expect(result.content[0]!.text).toBe("User cancelled the question.");
134
+ expect(result.structuredContent).toEqual({
135
+ cancelled: true,
136
+ results: [
137
+ {
138
+ id: "q1",
139
+ question: "Which option?",
140
+ description: "Some context",
141
+ options: ["A", "B"],
142
+ multi: false,
143
+ selectedOptions: [],
144
+ },
145
+ ],
146
+ });
147
+ expect(check(tool, result.structuredContent)).toBe(true);
148
+ expect(result.isError).toBeUndefined();
149
+ });
150
+
151
+ it("single-question TUI → one result, cancelled false, custom input only when given", async () => {
152
+ vi.mocked(askSingleQuestionWithInlineNote).mockResolvedValue({ selectedOptions: ["A"] });
153
+
154
+ const tool = captureTool();
155
+ const result = await tool.execute("t2", oneQuestion, undefined, undefined, { hasUI: true, ui: {} });
156
+
157
+ expect(result.structuredContent).toEqual({
158
+ cancelled: false,
159
+ results: [
160
+ {
161
+ id: "q1",
162
+ question: "Which option?",
163
+ description: "Some context",
164
+ options: ["A", "B"],
165
+ multi: false,
166
+ selectedOptions: ["A"],
167
+ },
168
+ ],
169
+ });
170
+ expect(check(tool, result.structuredContent)).toBe(true);
171
+ });
172
+
173
+ it("single-question TUI with no selection → cancelled true (the text says (cancelled))", async () => {
174
+ vi.mocked(askSingleQuestionWithInlineNote).mockResolvedValue({ selectedOptions: [] });
175
+
176
+ const tool = captureTool();
177
+ const result = await tool.execute("t3", oneQuestion, undefined, undefined, { hasUI: true, ui: {} });
178
+
179
+ expect(result.content[0]!.text).toContain("(cancelled)");
180
+ expect((result.structuredContent as { cancelled: boolean }).cancelled).toBe(true);
181
+ expect(check(tool, result.structuredContent)).toBe(true);
182
+ });
183
+
184
+ it("a typed answer with nothing selected → cancelled false, customInput present", async () => {
185
+ // `cancelled` is the tool's own test — nothing selected AND nothing typed —
186
+ // so a typed answer is not a cancellation even with an empty selection, and
187
+ // `customInput` is the only field that says so.
188
+ bridgeState.active = true;
189
+ bridgeState.ask.mockResolvedValue({ cancelled: false, results: [{ id: "q1", selectedOptions: [], customInput: "typed instead" }] });
190
+
191
+ const tool = captureTool();
192
+ const result = await tool.execute("t5", oneQuestion, undefined, undefined, { hasUI: true, ui: {} });
193
+
194
+ const structured = result.structuredContent as { cancelled: boolean; results: Array<Record<string, unknown>> };
195
+ expect(structured.cancelled).toBe(false);
196
+ expect(structured.results[0]!.selectedOptions).toEqual([]);
197
+ expect(structured.results[0]!.customInput).toBe("typed instead");
198
+ expect(result.content[0]!.text).toContain("typed instead");
199
+ expect(check(tool, result.structuredContent)).toBe(true);
200
+ });
201
+
202
+ it("tabs dialog with two questions → two results, in question order", async () => {
203
+ vi.mocked(askQuestionsWithTabs).mockResolvedValue({
204
+ cancelled: false,
205
+ selections: [{ selectedOptions: ["A"] }, { selectedOptions: ["B"], customInput: "note" }],
206
+ });
207
+
208
+ const tool = captureTool();
209
+ const result = await tool.execute("t4", twoQuestions, undefined, undefined, { hasUI: true, ui: {} });
210
+
211
+ expect(result.structuredContent).toEqual({
212
+ cancelled: false,
213
+ results: [
214
+ {
215
+ id: "q1",
216
+ question: "First?",
217
+ options: ["A", "B"],
218
+ multi: false,
219
+ selectedOptions: ["A"],
220
+ },
221
+ {
222
+ id: "q2",
223
+ question: "Second?",
224
+ options: ["A", "B"],
225
+ multi: true,
226
+ selectedOptions: ["B"],
227
+ customInput: "note",
228
+ },
229
+ ],
230
+ });
231
+ expect(check(tool, result.structuredContent)).toBe(true);
232
+ });
233
+ });
package/src/tool.test.ts CHANGED
@@ -5,20 +5,22 @@ import * as path from "node:path";
5
5
  import * as fs from "node:fs";
6
6
  import { randomUUID } from "node:crypto";
7
7
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
8
- import { Events } from "@pi-archimedes/core/bus";
8
+ import { Events } from "@pi-archimedes/core/events";
9
9
  import { registerAskTool } from "./tool.js";
10
10
  import { registerIpcRelay } from "./ipc-relay.js";
11
11
  import { renderAskCall, renderAskResult } from "./renderer.js";
12
12
 
13
13
  // ── Mocks ────────────────────────────────────────────────────────────────────
14
14
  //
15
- // Bus: an in-memory pub/sub whose `emit` is a spy (records + dispatches), so
16
- // tests can assert on emitted payloads while still letting the relay's
17
- // `on(ASK_REQUEST)` subscription receive them.
15
+ // events: the host event bus (`pi.events`), an in-memory pub/sub whose `emit` is
16
+ // a spy (records + dispatches), so tests can assert on emitted payloads while
17
+ // still letting the relay's `on(ASK_REQUEST)` subscription receive them. One
18
+ // handle per test — the tool and the relay are handed the same object, exactly
19
+ // as two extensions in one pi process share one bus.
18
20
  // Bridge: a mutable state object — `active` flips per test, `ask` is a vi.fn.
19
21
  // Picker/dialog: mocked so the TUI branch never touches the real TUI.
20
22
 
21
- const { busState, bridgeState, eventsState } = vi.hoisted(() => {
23
+ const { bridgeState, eventsState } = vi.hoisted(() => {
22
24
  const listeners: Record<string, Array<(p: unknown) => void>> = {};
23
25
  const emit = vi.fn((event: string, payload: unknown) => {
24
26
  for (const fn of listeners[event] ?? []) {
@@ -37,7 +39,6 @@ const { busState, bridgeState, eventsState } = vi.hoisted(() => {
37
39
  };
38
40
  const ask = vi.fn();
39
41
  return {
40
- busState: { listeners, emit, on },
41
42
  bridgeState: {
42
43
  active: false,
43
44
  ask,
@@ -45,15 +46,10 @@ const { busState, bridgeState, eventsState } = vi.hoisted(() => {
45
46
  password: vi.fn(),
46
47
  state: vi.fn(() => "idle"),
47
48
  },
48
- eventsState: { emit: vi.fn() }, // pi.events — the shared extension event bus
49
+ eventsState: { emit, on }, // pi.events — the host's shared extension event bus
49
50
  };
50
51
  });
51
52
 
52
- vi.mock("@pi-archimedes/core/bus", async (importOriginal) => {
53
- const actual = await importOriginal() as typeof import("@pi-archimedes/core/bus");
54
- return { ...actual, getBus: () => ({ emit: busState.emit, on: busState.on }) };
55
- });
56
-
57
53
  vi.mock("@pi-archimedes/core/bridge", () => ({
58
54
  getBridge: () => bridgeState,
59
55
  }));
@@ -103,9 +99,9 @@ function makeParams(): { questions: unknown[] } {
103
99
  };
104
100
  }
105
101
 
106
- /** The payloads of the recorded `emit` calls for the given event. */
102
+ /** The payloads of the recorded `emit` calls for the given channel. */
107
103
  function emitted(event: string): Record<string, unknown>[] {
108
- return busState.emit.mock.calls
104
+ return eventsState.emit.mock.calls
109
105
  .filter((c) => c[0] === event)
110
106
  .map((c) => c[1]!) as Record<string, unknown>[];
111
107
  }
@@ -127,7 +123,6 @@ afterEach(() => {
127
123
  });
128
124
 
129
125
  beforeEach(() => {
130
- busState.emit.mockClear();
131
126
  eventsState.emit.mockClear();
132
127
  bridgeState.active = false;
133
128
  bridgeState.ask.mockReset();
@@ -299,8 +294,8 @@ describe("child branch (bridge inactive, no UI)", () => {
299
294
  const result = await tool.execute("tool-2", makeParams(), undefined, undefined, { hasUI: false });
300
295
 
301
296
  expect(result.content[0]!.text).toBe("User cancelled the question.");
302
- // The child branch does not emit on the bus
303
- expect(busState.emit).not.toHaveBeenCalled();
297
+ // The child branch does not emit on the handle
298
+ expect(eventsState.emit).not.toHaveBeenCalled();
304
299
  });
305
300
  });
306
301
 
@@ -365,7 +360,7 @@ describe("TUI branch (bridge inactive, has UI)", () => {
365
360
  const result = await tool.execute("tool-5", { questions: [] }, undefined, undefined, { hasUI: true, ui: {} });
366
361
 
367
362
  expect(result.content[0]!.text).toBe("Error: questions must not be empty");
368
- expect(busState.emit).not.toHaveBeenCalled();
363
+ expect(eventsState.emit).not.toHaveBeenCalled();
369
364
  // No dialog shown → no herdr pairing either
370
365
  expect(herdrEmits()).toEqual([]);
371
366
  });
@@ -476,7 +471,7 @@ describe("ipc-relay", () => {
476
471
  registerIpcRelay({ events: eventsState } as unknown as ExtensionAPI, () => ({ ui: {} } as unknown as ExtensionContext), unsubscribes);
477
472
  relayUnsubs.push(...unsubscribes);
478
473
 
479
- busState.emit(Events.ASK_REQUEST, {
474
+ eventsState.emit(Events.ASK_REQUEST, {
480
475
  source: "subagent:x",
481
476
  requestId: "relay-1",
482
477
  questions: [{ id: "q1", question: "Which?", options: [{ label: "A" }] }],
@@ -498,7 +493,7 @@ describe("ipc-relay", () => {
498
493
  registerIpcRelay({ events: eventsState } as unknown as ExtensionAPI, () => ({ ui: {} } as unknown as ExtensionContext), unsubscribes);
499
494
  relayUnsubs.push(...unsubscribes);
500
495
 
501
- busState.emit(Events.ASK_REQUEST, {
496
+ eventsState.emit(Events.ASK_REQUEST, {
502
497
  source: "subagent:x",
503
498
  requestId: "relay-2",
504
499
  questions: [{ id: "q1", question: "Which?", options: [{ label: "A" }] }],
@@ -520,7 +515,7 @@ describe("ipc-relay", () => {
520
515
  registerIpcRelay({ events: eventsState } as unknown as ExtensionAPI, () => ({ ui: {} } as unknown as ExtensionContext), unsubscribes);
521
516
  relayUnsubs.push(...unsubscribes);
522
517
 
523
- busState.emit(Events.ASK_REQUEST, {
518
+ eventsState.emit(Events.ASK_REQUEST, {
524
519
  source: "subagent:x",
525
520
  requestId: "relay-3",
526
521
  questions: [{ id: "q1", question: "Which?", options: [{ label: "A" }] }],
@@ -545,7 +540,7 @@ describe("ipc-relay", () => {
545
540
  registerIpcRelay({ events: eventsState } as unknown as ExtensionAPI, () => ({ ui: {} } as unknown as ExtensionContext), unsubscribes);
546
541
  relayUnsubs.push(...unsubscribes);
547
542
 
548
- busState.emit(Events.ASK_REQUEST, {
543
+ eventsState.emit(Events.ASK_REQUEST, {
549
544
  source: "subagent:x",
550
545
  requestId: "relay-4",
551
546
  questions: [{ id: "q1", question: "Which?", options: [{ label: "A" }] }],
package/src/tool.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { AgentToolResult, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { Type, type Static } from "typebox";
3
- import { getBus, Events } from "@pi-archimedes/core/bus";
3
+ import { Events, emitOn } from "@pi-archimedes/core/events";
4
4
  import { getBridge } from "@pi-archimedes/core/bridge";
5
5
  import { connect } from "node:net";
6
6
  import { randomUUID } from "node:crypto";
@@ -41,6 +41,70 @@ const AskParamsSchema = Type.Object({
41
41
 
42
42
  type AskParams = Static<typeof AskParamsSchema>;
43
43
 
44
+ /**
45
+ * The machine-readable answer set.
46
+ *
47
+ * Same referee as elsewhere in this task: pi hands this to a codemode script in
48
+ * place of the text and validates nothing, so `tool.output-schema.test.ts` runs
49
+ * the handler and checks the payload against the declaration.
50
+ *
51
+ * `results` is the same `QuestionResult[]` the renderer's `details.results`
52
+ * carries, and `cancelled` is the one fact the text states only in prose
53
+ * (`Selected: (cancelled)` / `User cancelled the question.`): the user answered
54
+ * nothing. `ask` has no failure result — a crashed picker still throws, because
55
+ * that is an unexpected error rather than a reported one.
56
+ */
57
+ const AskAnswerItemSchema = Type.Object({
58
+ id: Type.String(),
59
+ question: Type.String(),
60
+ description: Type.Optional(Type.String()),
61
+ options: Type.Array(Type.String()),
62
+ multi: Type.Boolean(),
63
+ selectedOptions: Type.Array(Type.String()),
64
+ customInput: Type.Optional(Type.String()),
65
+ });
66
+
67
+ const AskOutputSchema = Type.Object({
68
+ cancelled: Type.Boolean({ description: "True when nothing was selected and nothing was typed for any question" }),
69
+ results: Type.Array(AskAnswerItemSchema),
70
+ });
71
+
72
+ /** The JSON-shaped copy of one answer (undefined fields are omitted, not null). */
73
+ function toAnswerOutput(result: QuestionResult): {
74
+ id: string;
75
+ question: string;
76
+ description?: string;
77
+ options: string[];
78
+ multi: boolean;
79
+ selectedOptions: string[];
80
+ customInput?: string;
81
+ } {
82
+ return {
83
+ id: result.id,
84
+ question: result.question,
85
+ ...(result.description !== undefined && { description: result.description }),
86
+ options: [...result.options],
87
+ multi: result.multi,
88
+ selectedOptions: [...result.selectedOptions],
89
+ ...(result.customInput !== undefined && { customInput: result.customInput }),
90
+ };
91
+ }
92
+
93
+ /**
94
+ * The `structuredContent` for a completed round. `cancelled` uses the tool's own
95
+ * cancel test — nothing selected and nothing typed, for every question — which is
96
+ * what makes the bridge and headless branches emit the cancel text.
97
+ */
98
+ function buildAskStructuredContent(results: QuestionResult[]): {
99
+ cancelled: boolean;
100
+ results: ReturnType<typeof toAnswerOutput>[];
101
+ } {
102
+ return {
103
+ cancelled: results.every((result) => result.selectedOptions.length === 0 && !result.customInput),
104
+ results: results.map(toAnswerOutput),
105
+ };
106
+ }
107
+
44
108
  // ── Types ────────────────────────────────────────────────────────────────────
45
109
 
46
110
  export interface QuestionResult {
@@ -230,6 +294,20 @@ export function registerAskTool(pi: ExtensionAPI): void {
230
294
  label: "Ask",
231
295
  description: ASK_TOOL_DESCRIPTION,
232
296
  parameters: AskParamsSchema,
297
+ outputSchema: AskOutputSchema,
298
+ /**
299
+ * Declared to the model, never callable from a codemode script — the use pi
300
+ * documents the exposure for ("orchestrating or interactive tools").
301
+ * `pi: dist/core/agent-session.js`, `_getCallableTools()`: only `direct` +
302
+ * active, `codemode` and `deferred` tools are callable, and codemode builds
303
+ * its `tools` from that set.
304
+ *
305
+ * This costs a capability, measured before it was taken: with the old
306
+ * default `direct` exposure a codemode script's `tools.ask({...})` resolved
307
+ * (to `{"cancelled":true,...}` in a headless session, where there is no UI
308
+ * to answer). Scripts lose that; the model's own calls are unaffected.
309
+ */
310
+ exposure: "model-only",
233
311
  renderCall: renderAskCall,
234
312
  renderResult: renderAskResult,
235
313
 
@@ -241,7 +319,7 @@ export function registerAskTool(pi: ExtensionAPI): void {
241
319
  // refcount never leaks and the state machine is not stuck at blocked.
242
320
  if (getBridge().active) {
243
321
  const requestId = randomUUID();
244
- getBus().emit(Events.ASK_REQUEST, {
322
+ emitOn(pi.events, Events.ASK_REQUEST, {
245
323
  source: "main",
246
324
  requestId,
247
325
  questions: params.questions,
@@ -258,18 +336,20 @@ export function registerAskTool(pi: ExtensionAPI): void {
258
336
  } catch {
259
337
  response = { cancelled: true, results: params.questions.map((q) => ({ id: q.id, selectedOptions: [] })) };
260
338
  }
261
- getBus().emit(Events.ASK_RESPONSE, { requestId, cancelled: response.cancelled, results: response.results });
339
+ emitOn(pi.events, Events.ASK_RESPONSE, { requestId, cancelled: response.cancelled, results: response.results });
262
340
 
263
341
  const results = responseToResults(response, params.questions);
264
342
  if (response.cancelled && results.every((r) => r.selectedOptions.length === 0)) {
265
343
  return {
266
344
  content: [{ type: "text", text: "User cancelled the question." }],
267
345
  details: { results, customInput: undefined, description: undefined } satisfies AskToolDetails,
346
+ structuredContent: buildAskStructuredContent(results),
268
347
  };
269
348
  }
270
349
  return {
271
350
  content: [{ type: "text", text: buildAskSessionContent(results) }],
272
351
  details: { results, customInput: undefined, description: undefined } satisfies AskToolDetails,
352
+ structuredContent: buildAskStructuredContent(results),
273
353
  };
274
354
  }
275
355
 
@@ -346,12 +426,14 @@ export function registerAskTool(pi: ExtensionAPI): void {
346
426
  return {
347
427
  content: [{ type: "text", text: "User cancelled the question." }],
348
428
  details: { results, customInput: undefined, description: undefined } satisfies AskToolDetails,
429
+ structuredContent: buildAskStructuredContent(results),
349
430
  };
350
431
  }
351
432
 
352
433
  return {
353
434
  content: [{ type: "text", text: buildAskSessionContent(results) }],
354
435
  details: { results, customInput: undefined, description: undefined } satisfies AskToolDetails,
436
+ structuredContent: buildAskStructuredContent(results),
355
437
  };
356
438
  }
357
439
 
@@ -362,8 +444,8 @@ export function registerAskTool(pi: ExtensionAPI): void {
362
444
  };
363
445
  }
364
446
 
365
- // Emit bus event so notify (if installed) can schedule a desktop notification
366
- getBus().emit(Events.ASK_REQUEST, { source: "main", requestId: randomUUID(), questions: params.questions, toolCallId: _toolCallId });
447
+ // Announce the ask on the handle so notify (if installed) can schedule a desktop notification
448
+ emitOn(pi.events, Events.ASK_REQUEST, { source: "main", requestId: randomUUID(), questions: params.questions, toolCallId: _toolCallId });
367
449
 
368
450
  // Report to herdr's managed extension (inert unless herdr spawned pi — the
369
451
  // event is a no-op otherwise). One pair per dialog; the finally pairs it on
@@ -421,6 +503,7 @@ async function executeTuiAsk(
421
503
  return {
422
504
  content: [{ type: "text", text: buildAskSessionContent([result]) }],
423
505
  details,
506
+ structuredContent: buildAskStructuredContent([result]),
424
507
  };
425
508
  }
426
509
 
@@ -444,5 +527,6 @@ async function executeTuiAsk(
444
527
  return {
445
528
  content: [{ type: "text", text: buildAskSessionContent(results) }],
446
529
  details: { results, customInput: undefined, description: undefined } satisfies AskToolDetails,
530
+ structuredContent: buildAskStructuredContent(results),
447
531
  };
448
532
  }