@pi-archimedes/ask 2.7.3 → 2.9.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.7.3",
3
+ "version": "2.9.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/danielcherubini/pi-archimedes.git"
@@ -15,7 +15,7 @@
15
15
  ],
16
16
  "main": "./src/index.ts",
17
17
  "dependencies": {
18
- "@pi-archimedes/core": "2.7.3"
18
+ "@pi-archimedes/core": "2.9.0"
19
19
  },
20
20
  "peerDependencies": {
21
21
  "@earendil-works/pi-coding-agent": ">=0.1.0",
@@ -23,8 +23,8 @@
23
23
  "typebox": ">=1.1.0"
24
24
  },
25
25
  "devDependencies": {
26
- "@earendil-works/pi-coding-agent": "^0.85.1",
27
- "@earendil-works/pi-tui": "^0.85.1",
26
+ "@earendil-works/pi-coding-agent": "^0.87.0",
27
+ "@earendil-works/pi-tui": "^0.87.0",
28
28
  "typebox": "^1.1.38",
29
29
  "typescript": "^6.0.0"
30
30
  },
@@ -0,0 +1,43 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import { emitBlocked, blockedLabel } from "./herdr.js";
4
+
5
+ // ── blockedLabel ────────────────────────────────────────────────────────────
6
+
7
+ describe("blockedLabel", () => {
8
+ it("uses the first question, collapsed to a single line", () => {
9
+ expect(blockedLabel([{ question: " Which\n\tframework? " }])).toBe("Which framework?");
10
+ });
11
+
12
+ it("truncates long questions at 80 chars with an ellipsis", () => {
13
+ const label = blockedLabel([{ question: "a".repeat(120) }]);
14
+ expect(label).toHaveLength(80);
15
+ expect(label.endsWith("…")).toBe(true);
16
+ });
17
+
18
+ it("keeps 80-char questions intact", () => {
19
+ const q = "a".repeat(80);
20
+ expect(blockedLabel([{ question: q }])).toBe(q);
21
+ });
22
+
23
+ it("falls back to 'question' for a missing or blank first question", () => {
24
+ expect(blockedLabel([])).toBe("question");
25
+ expect(blockedLabel([{ question: " " }])).toBe("question");
26
+ });
27
+ });
28
+
29
+ // ── emitBlocked ─────────────────────────────────────────────────────────────
30
+
31
+ describe("emitBlocked", () => {
32
+ it("emits 'herdr:blocked' on pi.events with the label only when active", () => {
33
+ const emit = vi.fn();
34
+ const pi = { events: { emit } } as unknown as ExtensionAPI;
35
+
36
+ emitBlocked(pi, true, "Which?");
37
+ emitBlocked(pi, false);
38
+
39
+ expect(emit).toHaveBeenCalledTimes(2);
40
+ expect(emit).toHaveBeenNthCalledWith(1, "herdr:blocked", { active: true, label: "Which?" });
41
+ expect(emit).toHaveBeenNthCalledWith(2, "herdr:blocked", { active: false, label: undefined });
42
+ });
43
+ });
package/src/herdr.ts ADDED
@@ -0,0 +1,34 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+
3
+ /**
4
+ * Report the ask dialog to herdr's managed extension (herdr-agent-state.ts).
5
+ *
6
+ * That extension listens for "herdr:blocked" on pi's SHARED event bus
7
+ * (`pi.events` — every extension in the process sees it) and refcounts the
8
+ * blocked state: `active: true` → +1 (message = label), `active: false` → −1.
9
+ * It is TUI-only and inert unless herdr spawned pi with `HERDR_ENV=1` +
10
+ * `HERDR_SOCKET_PATH` + `HERDR_PANE_ID`, so when herdr is not active the
11
+ * event has no live listeners and the emit is a no-op — the same pattern as
12
+ * the `ASK_REQUEST` bus emit (a notify hook). No env gate is needed here.
13
+ *
14
+ * The caller MUST pair every `active: true` with exactly one `active: false`
15
+ * (try/finally) so the refcount never leaks — an unpaired increment would
16
+ * leave the pane stuck at "blocked".
17
+ */
18
+ export function emitBlocked(pi: ExtensionAPI, active: boolean, label?: string): void {
19
+ pi.events.emit("herdr:blocked", { active, label });
20
+ }
21
+
22
+ /**
23
+ * The label herdr shows while the agent is blocked: the first question,
24
+ * collapsed to one line and truncated (herdr renders it in the pane's
25
+ * status line). One pair per dialog, not per question — the dialog is one
26
+ * UI, and the refcount is per pending prompt.
27
+ */
28
+ export function blockedLabel(questions: Array<{ question: string }>): string {
29
+ const question = questions[0]?.question;
30
+ if (!question) return "question";
31
+ const label = question.replace(/\s+/g, " ").trim();
32
+ if (label.length === 0) return "question";
33
+ return label.length > 80 ? `${label.slice(0, 79)}…` : label;
34
+ }
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { registerIpcRelay } from "./ipc-relay.js";
3
3
  import { registerAskTool } from "./tool.js";
4
+ export { renderAskCall, renderAskResult } from "./renderer.js";
4
5
 
5
6
  export function registerAsk(pi: ExtensionAPI) {
6
7
  const unsubscribes: Array<() => void> = [];
package/src/ipc-relay.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { getBus, Events } from "@pi-archimedes/core/bus";
3
+ import { getBridge } from "@pi-archimedes/core/bridge";
3
4
  import { type AskQuestion, type AskSelection } from "./selection.js";
4
5
  import { askSingleQuestionWithInlineNote } from "./picker.js";
5
6
  import { askQuestionsWithTabs } from "./dialog.js";
7
+ import { emitBlocked, blockedLabel } from "./herdr.js";
6
8
 
7
9
  export function registerIpcRelay(
8
10
  pi: ExtensionAPI,
@@ -19,11 +21,18 @@ export function registerIpcRelay(
19
21
  // Skip main agent — it shows its own UI in the tool handler
20
22
  if (data.source === "main") return;
21
23
 
24
+ // Bridge mode: the bridge forwards subagent asks to the Client — the
25
+ // relay must stay inert (per-message gate: registerIpcRelay runs at
26
+ // extension load when bridge.active is false in every mode, and ctx.mode
27
+ // is only captured at the first session_start, so a registration-time
28
+ // gate would silently reintroduce the double-consumption bug).
29
+ if (getBridge().active) return;
30
+
22
31
  if (!data.questions || data.questions.length === 0) return;
23
32
 
24
33
  // Defer to next tick so TUI can process current state
25
34
  await new Promise((resolve) => setImmediate(resolve));
26
- await handleAskRequest(data, getCtx);
35
+ await handleAskRequest(data, getCtx, (active, label) => emitBlocked(pi, active, label));
27
36
  });
28
37
  unsubscribes.push(unsubAskRequest);
29
38
  }
@@ -34,6 +43,7 @@ async function handleAskRequest(
34
43
  questions: AskQuestion[];
35
44
  },
36
45
  getCtx: () => ExtensionContext | undefined,
46
+ reportBlocked: (active: boolean, label?: string) => void,
37
47
  ): Promise<void> {
38
48
  const ctx = getCtx();
39
49
  if (!ctx?.ui) return;
@@ -42,6 +52,12 @@ async function handleAskRequest(
42
52
  let cancelled = true;
43
53
  let selections: AskSelection[] = [];
44
54
 
55
+ // Report to herdr's managed extension: the PARENT's TUI is the surface this
56
+ // subagent's dialog appears on (the child process is headless and its own
57
+ // herdr extension is inert). Paired in the finally — the existing catch
58
+ // converts a picker error into a cancelled result, so the dialog always
59
+ // settles.
60
+ reportBlocked(true, blockedLabel(questions));
45
61
  try {
46
62
  if (questions.length === 1) {
47
63
  const q = questions[0]!;
@@ -69,6 +85,8 @@ async function handleAskRequest(
69
85
  } catch {
70
86
  cancelled = true;
71
87
  selections = questions.map(() => ({ selectedOptions: [] }));
88
+ } finally {
89
+ reportBlocked(false);
72
90
  }
73
91
 
74
92
  // Build results matching the request's questions
@@ -0,0 +1,225 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { Text } from "@earendil-works/pi-tui";
3
+ import { renderAskCall, renderAskResult } from "./renderer.js";
4
+
5
+ // Mocking dependencies if necessary
6
+ vi.mock("@pi-archimedes/core/tool-render", () => ({
7
+ renderToolHeader: (name: string) => `Header:${name}`,
8
+ renderStatusLabel: (status: string, message: string) => `[${status}] ${message}`,
9
+ }));
10
+
11
+ function createMockText() {
12
+ const text = new Text("", 0, 0) as any;
13
+ text._text = "";
14
+ text.setText = (t: string) => { text._text = t; };
15
+ text.getText = () => text._text;
16
+ return text;
17
+ }
18
+
19
+ const mockTheme = {
20
+ fg: (color: string, text: string) => `${color}:${text}`,
21
+ } as any;
22
+
23
+ describe("renderer", () => {
24
+ it("renderAskCall renders header", () => {
25
+ const text = createMockText();
26
+ renderAskCall({}, mockTheme, { lastComponent: text });
27
+ expect(text.getText()).toBe("Header:ask");
28
+ });
29
+
30
+ it("renderAskResult with isPartial: true renders waiting", () => {
31
+ const text = createMockText();
32
+ renderAskResult({}, { isPartial: true }, mockTheme, { lastComponent: text });
33
+ expect(text.getText()).toBe("[running] waiting for input...");
34
+ });
35
+
36
+ it("renderAskResult completed single question renders correct selection", () => {
37
+ const text = createMockText();
38
+ const result = {
39
+ details: {
40
+ results: [{
41
+ id: "test",
42
+ question: "q",
43
+ options: ["A", "B"],
44
+ multi: false,
45
+ selectedOptions: ["A"],
46
+ }]
47
+ }
48
+ };
49
+ renderAskResult(result, {}, mockTheme, { lastComponent: text });
50
+ expect(text.getText()).toBe("[success] A");
51
+ });
52
+
53
+ it("renderAskResult completed multi-select renders [A, B]", () => {
54
+ const text = createMockText();
55
+ const result = {
56
+ details: {
57
+ results: [{
58
+ id: "test",
59
+ question: "q",
60
+ options: ["A", "B"],
61
+ multi: true,
62
+ selectedOptions: ["A", "B"],
63
+ }]
64
+ }
65
+ };
66
+ renderAskResult(result, {}, mockTheme, { lastComponent: text });
67
+ expect(text.getText()).toBe("[success] [A, B]");
68
+ });
69
+
70
+ it("renderAskResult completed custom input renders custom note", () => {
71
+ const text = createMockText();
72
+ const result = {
73
+ details: {
74
+ results: [{
75
+ id: "test",
76
+ question: "q",
77
+ options: ["A"],
78
+ multi: false,
79
+ selectedOptions: [],
80
+ customInput: "custom note"
81
+ }]
82
+ }
83
+ };
84
+ renderAskResult(result, {}, mockTheme, { lastComponent: text });
85
+ expect(text.getText()).toBe("[success] \"custom note\"");
86
+ });
87
+
88
+ it("renderAskResult completed combo choice + custom input renders A + Other", () => {
89
+ const text = createMockText();
90
+ const result = {
91
+ details: {
92
+ results: [{
93
+ id: "test",
94
+ question: "q",
95
+ options: ["A"],
96
+ multi: false,
97
+ selectedOptions: ["A"],
98
+ customInput: "custom note"
99
+ }]
100
+ }
101
+ };
102
+ renderAskResult(result, {}, mockTheme, { lastComponent: text });
103
+ expect(text.getText()).toBe("[success] A + Other: \"custom note\"");
104
+ });
105
+
106
+ it("renderAskResult completed multiple questions renders comma separated", () => {
107
+ const text = createMockText();
108
+ const result = {
109
+ details: {
110
+ results: [
111
+ { id: "auth", question: "q", options: ["A"], multi: false, selectedOptions: ["OAuth"] },
112
+ { id: "cache", question: "q", options: ["B"], multi: false, selectedOptions: ["Redis"] }
113
+ ]
114
+ }
115
+ };
116
+ renderAskResult(result, {}, mockTheme, { lastComponent: text });
117
+ expect(text.getText()).toBe("[success] auth: OAuth, cache: Redis");
118
+ });
119
+
120
+ it("renderAskResult sanitizes multiline and control characters in collapsed status row", () => {
121
+ const text = createMockText();
122
+ const result = {
123
+ details: {
124
+ results: [{
125
+ id: "note\n1",
126
+ question: "q",
127
+ options: ["Option\r\nLine"],
128
+ multi: false,
129
+ selectedOptions: ["Option\r\nLine\x00"],
130
+ customInput: "Line 1\nLine 2\t\x07extra"
131
+ }]
132
+ }
133
+ };
134
+ renderAskResult(result, {}, mockTheme, { lastComponent: text });
135
+ expect(text.getText()).toBe("[success] Option Line + Other: \"Line 1 Line 2 extra\"");
136
+ });
137
+
138
+ it("renderAskResult cancelled renders (cancelled)", () => {
139
+ const text = createMockText();
140
+ const result = {
141
+ content: [{ type: "text", text: "User cancelled the question." }],
142
+ details: { results: [] }
143
+ };
144
+ renderAskResult(result, {}, mockTheme, { lastComponent: text });
145
+ expect(text.getText()).toBe("[error] (cancelled)");
146
+ });
147
+
148
+ it("renderAskResult error renders failed", () => {
149
+ const text = createMockText();
150
+ renderAskResult({}, {}, mockTheme, { lastComponent: text, isError: true });
151
+ expect(text.getText()).toBe("[error] failed");
152
+ });
153
+
154
+ it("renderAskResult expanded renders clean Q&A breakdown", () => {
155
+ const text = createMockText();
156
+ const result = {
157
+ details: {
158
+ results: [{
159
+ id: "test",
160
+ question: "Question prompt?",
161
+ options: ["A"],
162
+ multi: false,
163
+ selectedOptions: ["A"],
164
+ }]
165
+ }
166
+ };
167
+ renderAskResult(result, { expanded: true }, mockTheme, { lastComponent: text });
168
+ expect(text.getText()).toContain("dim:Question prompt?");
169
+ expect(text.getText()).toContain("success:✓");
170
+ expect(text.getText()).toContain("toolOutput:A");
171
+ });
172
+
173
+ it("renderAskResult expanded renders cancelled state", () => {
174
+ const text = createMockText();
175
+ const result = {
176
+ content: [{ type: "text", text: "User cancelled the question." }],
177
+ details: { results: [] }
178
+ };
179
+ renderAskResult(result, { expanded: true }, mockTheme, { lastComponent: text });
180
+ expect(text.getText()).toBe("error:✗ (cancelled)");
181
+ });
182
+
183
+ it("renderAskResult expanded renders error state", () => {
184
+ const text = createMockText();
185
+ renderAskResult({}, { expanded: true }, mockTheme, { lastComponent: text, isError: true });
186
+ expect(text.getText()).toBe("error:failed");
187
+ });
188
+
189
+ it("renderAskResult expanded renders empty results", () => {
190
+ const text = createMockText();
191
+ const result = { details: { results: [] } };
192
+ renderAskResult(result, { expanded: true }, mockTheme, { lastComponent: text });
193
+ expect(text.getText()).toBe("error:failed");
194
+ });
195
+
196
+ it("renderAskResult legacy details format", () => {
197
+ const text = createMockText();
198
+ const result = {
199
+ details: {
200
+ id: "test",
201
+ question: "q",
202
+ }
203
+ };
204
+ renderAskResult(result, {}, mockTheme, { lastComponent: text });
205
+ expect(text.getText()).toBe("[error] (cancelled)");
206
+ });
207
+
208
+ it("renderAskCall catch fallback", () => {
209
+ const text = createMockText();
210
+ const theme = { fg: () => { throw new Error(); } } as any;
211
+ renderAskCall({}, theme, { lastComponent: text });
212
+ expect(text.getText()).toBe("Header:ask");
213
+ });
214
+
215
+ it("renderAskResult catch fallback", () => {
216
+ const text = createMockText();
217
+ renderAskResult(null as any, {}, { fg: () => { throw new Error(); } } as any, { lastComponent: text });
218
+ expect(text.getText()).toBe("");
219
+ });
220
+
221
+ it("renderAskResult reuseText fallback", () => {
222
+ const text = renderAskResult({}, {}, mockTheme, { lastComponent: { something: "else" } });
223
+ expect(text).toBeInstanceOf(Text);
224
+ });
225
+ });
@@ -0,0 +1,114 @@
1
+ import { Text } from "@earendil-works/pi-tui";
2
+ import type { Component } from "@earendil-works/pi-tui";
3
+ import type { Theme } from "@earendil-works/pi-coding-agent";
4
+ import { renderToolHeader, renderStatusLabel } from "@pi-archimedes/core/tool-render";
5
+ import { formatSelectionForSummary, type QuestionResult } from "./tool.js";
6
+
7
+ export type RenderContext = {
8
+ lastComponent?: Component;
9
+ isError?: boolean;
10
+ expanded?: boolean;
11
+ isPartial?: boolean;
12
+ };
13
+
14
+ export type RenderOptions = {
15
+ expanded?: boolean;
16
+ isPartial?: boolean;
17
+ };
18
+
19
+ function reuseText(context?: RenderContext): Text {
20
+ return (context?.lastComponent instanceof Text
21
+ ? context.lastComponent
22
+ : new Text("", 0, 0)) as Text;
23
+ }
24
+
25
+ function extractQuestionResults(result: unknown, context?: RenderContext): { results: QuestionResult[]; isCancelled: boolean; isError: boolean; errorMessage: string | undefined } {
26
+ const r = result as { content?: Array<{ type: string; text?: string }>; details?: { results?: QuestionResult[]; id?: string; question?: string; description?: string; options?: string[]; multi?: boolean; selectedOptions?: string[]; customInput?: string } };
27
+
28
+ let results: QuestionResult[] = [];
29
+ if (r.details?.results && r.details.results.length > 0) {
30
+ results = r.details.results;
31
+ } else if (r.details?.id && r.details?.question) {
32
+ results = [{
33
+ id: r.details.id,
34
+ question: r.details.question,
35
+ description: r.details.description,
36
+ options: r.details.options ?? [],
37
+ multi: Boolean(r.details.multi),
38
+ selectedOptions: r.details.selectedOptions ?? [],
39
+ customInput: r.details.customInput,
40
+ }];
41
+ }
42
+
43
+ const isCancelled = (results.length > 0 && results.every(res => res.selectedOptions.length === 0 && !res.customInput)) ||
44
+ (r.content?.some(c => c.text?.match(/user cancelled the question/i)) ?? false);
45
+
46
+ const isError = context?.isError === true || results.length === 0;
47
+ const errorMessage = isError ? (r.content?.[0]?.text ?? "failed") : undefined;
48
+
49
+ return { results, isCancelled, isError, errorMessage };
50
+ }
51
+
52
+ export function formatExpandedBreakdown(results: QuestionResult[], isCancelled: boolean, theme: Theme): string {
53
+ if (isCancelled) return theme.fg("error", "✗ (cancelled)");
54
+ if (results.length === 0) return theme.fg("muted", "(no details)");
55
+
56
+ return results.map(q =>
57
+ `${theme.fg("dim", q.question)}\n ${theme.fg("success", "✓")} ${theme.fg("toolOutput", formatSelectionForSummary(q))}`
58
+ ).join("\n\n");
59
+ }
60
+
61
+ function sanitizeForStatusRow(value: string): string {
62
+ return value
63
+ .replace(/[\r\n\t]/g, " ")
64
+ .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "")
65
+ .replace(/\s{2,}/g, " ")
66
+ .trim();
67
+ }
68
+
69
+ export function renderAskCall(args: unknown, theme: Theme, context?: unknown): Text {
70
+ const text = reuseText(context as RenderContext | undefined);
71
+ try {
72
+ text.setText(renderToolHeader("ask", undefined, theme));
73
+ } catch {
74
+ text.setText("ask");
75
+ }
76
+ return text;
77
+ }
78
+
79
+ export function renderAskResult(result: unknown, options: RenderOptions = {}, theme: Theme, context?: unknown): Text {
80
+ const text = reuseText(context as RenderContext | undefined);
81
+ const ctx = context as RenderContext | undefined;
82
+
83
+ try {
84
+ const expanded = options?.expanded ?? ctx?.expanded ?? false;
85
+ const isPartial = options?.isPartial ?? ctx?.isPartial ?? false;
86
+ const { results, isCancelled, isError, errorMessage } = extractQuestionResults(result, ctx);
87
+ if (expanded) {
88
+ if (isCancelled) {
89
+ text.setText(formatExpandedBreakdown(results, true, theme));
90
+ } else if (isError) {
91
+ text.setText(theme.fg("error", errorMessage ?? "failed"));
92
+ } else {
93
+ text.setText(formatExpandedBreakdown(results, false, theme));
94
+ }
95
+ } else {
96
+ if (isPartial) text.setText(renderStatusLabel("running", "waiting for input...", theme));
97
+ else if (isCancelled) text.setText(renderStatusLabel("error", "(cancelled)", theme));
98
+ else if (isError) text.setText(renderStatusLabel("error", sanitizeForStatusRow(errorMessage ?? "failed"), theme));
99
+ else if (results.length === 1) {
100
+ const first = results[0];
101
+ const label = sanitizeForStatusRow(first ? formatSelectionForSummary(first) : "(unknown)");
102
+ text.setText(renderStatusLabel("success", label, theme));
103
+ } else if (results.length > 1) {
104
+ const summary = sanitizeForStatusRow(results.map((r) => `${r.id}: ${formatSelectionForSummary(r)}`).join(", "));
105
+ text.setText(renderStatusLabel("success", summary, theme));
106
+ } else {
107
+ text.setText(renderStatusLabel("error", "failed", theme));
108
+ }
109
+ }
110
+ } catch {
111
+ text.setText("");
112
+ }
113
+ return text;
114
+ }
@@ -0,0 +1,564 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import * as net from "node:net";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import * as fs from "node:fs";
6
+ import { randomUUID } from "node:crypto";
7
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
8
+ import { Events } from "@pi-archimedes/core/bus";
9
+ import { registerAskTool } from "./tool.js";
10
+ import { registerIpcRelay } from "./ipc-relay.js";
11
+ import { renderAskCall, renderAskResult } from "./renderer.js";
12
+
13
+ // ── Mocks ────────────────────────────────────────────────────────────────────
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.
18
+ // Bridge: a mutable state object — `active` flips per test, `ask` is a vi.fn.
19
+ // Picker/dialog: mocked so the TUI branch never touches the real TUI.
20
+
21
+ const { busState, bridgeState, eventsState } = vi.hoisted(() => {
22
+ const listeners: Record<string, Array<(p: unknown) => void>> = {};
23
+ const emit = vi.fn((event: string, payload: unknown) => {
24
+ for (const fn of listeners[event] ?? []) {
25
+ Promise.resolve(fn(payload)).catch(() => {
26
+ /* listener errors are not under test here */
27
+ });
28
+ }
29
+ });
30
+ const on = (event: string, fn: (p: unknown) => void) => {
31
+ (listeners[event] ??= []).push(fn);
32
+ return () => {
33
+ const subs = listeners[event] ?? [];
34
+ const i = subs.indexOf(fn);
35
+ if (i !== -1) subs.splice(i, 1);
36
+ };
37
+ };
38
+ const ask = vi.fn();
39
+ return {
40
+ busState: { listeners, emit, on },
41
+ bridgeState: {
42
+ active: false,
43
+ ask,
44
+ confirm: vi.fn(),
45
+ password: vi.fn(),
46
+ state: vi.fn(() => "idle"),
47
+ },
48
+ eventsState: { emit: vi.fn() }, // pi.events — the shared extension event bus
49
+ };
50
+ });
51
+
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
+ vi.mock("@pi-archimedes/core/bridge", () => ({
58
+ getBridge: () => bridgeState,
59
+ }));
60
+
61
+ vi.mock("./picker.js", () => ({
62
+ askSingleQuestionWithInlineNote: vi.fn(),
63
+ }));
64
+
65
+ vi.mock("./dialog.js", () => ({
66
+ askQuestionsWithTabs: vi.fn(),
67
+ }));
68
+
69
+ const { askSingleQuestionWithInlineNote } = await import("./picker.js");
70
+ const { askQuestionsWithTabs } = await import("./dialog.js");
71
+
72
+ // ── Helpers ──────────────────────────────────────────────────────────────────
73
+
74
+ function captureTool(): {
75
+ execute: (
76
+ id: string,
77
+ params: { questions: unknown[] },
78
+ signal: unknown,
79
+ onUpdate: unknown,
80
+ ctx: unknown,
81
+ ) => Promise<{ content: Array<{ type: string; text: string }>; details: unknown }>;
82
+ renderCall?: unknown;
83
+ renderResult?: unknown;
84
+ } {
85
+ const registered: Array<Record<string, unknown>> = [];
86
+ registerAskTool({
87
+ registerTool: (t: Record<string, unknown>) => registered.push(t),
88
+ events: eventsState,
89
+ } as unknown as ExtensionAPI);
90
+ return registered[0] as ReturnType<typeof captureTool>;
91
+ }
92
+
93
+ function makeParams(): { questions: unknown[] } {
94
+ return {
95
+ questions: [
96
+ {
97
+ id: "q1",
98
+ question: "Which option?",
99
+ description: "Some context",
100
+ options: [{ label: "A" }, { label: "B" }],
101
+ },
102
+ ],
103
+ };
104
+ }
105
+
106
+ /** The payloads of the recorded `emit` calls for the given event. */
107
+ function emitted(event: string): Record<string, unknown>[] {
108
+ return busState.emit.mock.calls
109
+ .filter((c) => c[0] === event)
110
+ .map((c) => c[1]!) as Record<string, unknown>[];
111
+ }
112
+
113
+ /** The recorded `pi.events` "herdr:blocked" payloads, in order. */
114
+ function herdrEmits(): Array<{ active: boolean; label?: string }> {
115
+ return eventsState.emit.mock.calls
116
+ .filter((c) => c[0] === "herdr:blocked")
117
+ .map((c) => c[1]!) as Array<{ active: boolean; label?: string }>;
118
+ }
119
+
120
+ // Relay subscriptions are tracked file-wide and torn down in afterEach (not at
121
+ // the end of each test body) so a failing assertion cannot leak a live relay
122
+ // handler into the next test (double registration → double ASK_RESPONSE).
123
+ const relayUnsubs: Array<() => void> = [];
124
+
125
+ afterEach(() => {
126
+ relayUnsubs.splice(0).forEach((u) => u());
127
+ });
128
+
129
+ beforeEach(() => {
130
+ busState.emit.mockClear();
131
+ eventsState.emit.mockClear();
132
+ bridgeState.active = false;
133
+ bridgeState.ask.mockReset();
134
+ vi.mocked(askSingleQuestionWithInlineNote).mockReset();
135
+ vi.mocked(askQuestionsWithTabs).mockReset();
136
+ });
137
+
138
+ // ── Bridge branch (checked first) ────────────────────────────────────────────
139
+
140
+ describe("tool registration", () => {
141
+ it("registers the correct render functions", () => {
142
+ const tool = captureTool();
143
+ expect(tool.renderCall).toBe(renderAskCall);
144
+ expect(tool.renderResult).toBe(renderAskResult);
145
+ });
146
+ });
147
+
148
+ describe("bridge branch", () => {
149
+ it("emits ASK_REQUEST (main) with toolCallId, then a paired ASK_RESPONSE, and returns the built results", async () => {
150
+ bridgeState.active = true;
151
+ bridgeState.ask.mockResolvedValue({
152
+ cancelled: false,
153
+ results: [{ id: "q1", selectedOptions: ["B"], customInput: "extra" }],
154
+ });
155
+
156
+ const tool = captureTool();
157
+ const result = await tool.execute("tool-1", makeParams(), undefined, undefined, { hasUI: true, ui: {} });
158
+
159
+ // bridge.ask was called with the params + toolCallId + the turn's AbortSignal
160
+ // (undefined here — the test passes no signal)
161
+ expect(bridgeState.ask).toHaveBeenCalledTimes(1);
162
+ expect(bridgeState.ask).toHaveBeenCalledWith({ questions: makeParams().questions }, "tool-1", undefined);
163
+
164
+ // ASK_REQUEST (main) with toolCallId
165
+ expect(emitted(Events.ASK_REQUEST).length).toBe(1);
166
+ const req = emitted(Events.ASK_REQUEST)[0]!;
167
+ expect(req.source).toBe("main");
168
+ expect(req.toolCallId).toBe("tool-1");
169
+ expect(req.questions).toEqual(makeParams().questions);
170
+ expect(typeof req.requestId).toBe("string");
171
+
172
+ // Paired ASK_RESPONSE (same requestId, the bridge's response verbatim)
173
+ expect(emitted(Events.ASK_RESPONSE).length).toBe(1);
174
+ const resp = emitted(Events.ASK_RESPONSE)[0]!;
175
+ expect(resp.requestId).toBe(req.requestId);
176
+ expect(resp.cancelled).toBe(false);
177
+ expect(resp.results).toEqual([{ id: "q1", selectedOptions: ["B"], customInput: "extra" }]);
178
+
179
+ // Built results in the returned content
180
+ expect(result.content[0]!.text).toContain("q1: B + Other: \"extra\"");
181
+ });
182
+
183
+ it("a rejected bridge.ask() still emits the paired ASK_RESPONSE (cancelled) and returns the cancel text", async () => {
184
+ bridgeState.active = true;
185
+ bridgeState.ask.mockRejectedValue(new Error("bridge is not active"));
186
+
187
+ const tool = captureTool();
188
+ const result = await tool.execute("tool-1", makeParams(), undefined, undefined, { hasUI: true, ui: {} });
189
+
190
+ expect(emitted(Events.ASK_REQUEST).length).toBe(1);
191
+ expect(emitted(Events.ASK_RESPONSE).length).toBe(1);
192
+ const resp = emitted(Events.ASK_RESPONSE)[0]!;
193
+ expect(resp.requestId).toBe(emitted(Events.ASK_REQUEST)[0]!.requestId);
194
+ expect(resp.cancelled).toBe(true);
195
+ expect(resp.results).toEqual([{ id: "q1", selectedOptions: [] }]);
196
+ expect(result.content[0]!.text).toBe("User cancelled the question.");
197
+ });
198
+
199
+ it("a pre-aborted signal → cancelled content + paired cancelled ASK_RESPONSE", async () => {
200
+ bridgeState.active = true;
201
+ // Simulate the cancel-rejection: when the turn's signal is already aborted,
202
+ // bridge.ask() calls handle.cancel() directly (the abort event never fires),
203
+ // which settles the promise via the socket-close handler → a rejection.
204
+ bridgeState.ask.mockRejectedValue(new Error("bridge channel closed before response"));
205
+
206
+ const controller = new AbortController();
207
+ controller.abort(); // pre-aborted BEFORE execute() is invoked
208
+
209
+ const tool = captureTool();
210
+ const result = await tool.execute("tool-6", makeParams(), controller.signal, undefined, { hasUI: true, ui: {} });
211
+
212
+ // bridge.ask was called with the pre-aborted signal (3rd arg)
213
+ expect(bridgeState.ask).toHaveBeenCalledTimes(1);
214
+ expect(bridgeState.ask).toHaveBeenCalledWith(makeParams(), "tool-6", controller.signal);
215
+
216
+ // Paired cancelled ASK_RESPONSE (the tool's catch converts the
217
+ // cancel-rejection into a cancelled response so the refcount stays balanced)
218
+ expect(emitted(Events.ASK_REQUEST).length).toBe(1);
219
+ expect(emitted(Events.ASK_RESPONSE).length).toBe(1);
220
+ const resp = emitted(Events.ASK_RESPONSE)[0]!;
221
+ expect(resp.requestId).toBe(emitted(Events.ASK_REQUEST)[0]!.requestId);
222
+ expect(resp.cancelled).toBe(true);
223
+ expect(resp.results).toEqual([{ id: "q1", selectedOptions: [] }]);
224
+
225
+ // Cancelled content
226
+ expect(result.content[0]!.text).toBe("User cancelled the question.");
227
+ });
228
+ });
229
+
230
+ // ── Child (headless) branch — unchanged except toolCallId on the socket payload ──
231
+
232
+ describe("child branch (bridge inactive, no UI)", () => {
233
+ it("writes ask_request with toolCallId to PI_SUBAGENT_SOCKET and returns the response results", async () => {
234
+ bridgeState.active = false;
235
+ const socketPath = path.join(os.tmpdir(), `ask-tool-test-${randomUUID()}.sock`);
236
+ const received: Array<Record<string, unknown>> = [];
237
+ const server = net.createServer((socket) => {
238
+ let buffer = "";
239
+ socket.on("data", (chunk: Buffer) => {
240
+ buffer += chunk.toString("utf-8");
241
+ const lines = buffer.split("\n");
242
+ buffer = lines.pop() ?? "";
243
+ for (const line of lines) {
244
+ const trimmed = line.trim();
245
+ if (!trimmed) continue;
246
+ try {
247
+ const msg = JSON.parse(trimmed) as Record<string, unknown>;
248
+ if (msg.type === "ask_request") {
249
+ received.push(msg);
250
+ socket.write(
251
+ JSON.stringify({
252
+ type: "ask_response",
253
+ requestId: msg.requestId,
254
+ cancelled: false,
255
+ results: [{ id: "q1", selectedOptions: ["A"], customInput: "from-parent" }],
256
+ }) + "\n",
257
+ );
258
+ }
259
+ } catch {
260
+ /* malformed — ignore */
261
+ }
262
+ }
263
+ });
264
+ socket.on("error", () => {
265
+ /* connection dropped */
266
+ });
267
+ });
268
+ await new Promise<void>((resolve) => server.listen(socketPath, resolve));
269
+ process.env.PI_SUBAGENT_SOCKET = socketPath;
270
+
271
+ try {
272
+ const tool = captureTool();
273
+ const result = await tool.execute("tool-2", makeParams(), undefined, undefined, { hasUI: false });
274
+
275
+ expect(received.length).toBe(1);
276
+ expect(received[0]!.type).toBe("ask_request");
277
+ expect(received[0]!.toolCallId).toBe("tool-2");
278
+ expect(received[0]!.questions).toEqual(makeParams().questions);
279
+ expect(typeof received[0]!.requestId).toBe("string");
280
+
281
+ // The response results are built into the returned content
282
+ expect(result.content[0]!.text).toContain("q1: A + Other: \"from-parent\"");
283
+ } finally {
284
+ delete process.env.PI_SUBAGENT_SOCKET;
285
+ server.close();
286
+ try {
287
+ fs.unlinkSync(socketPath);
288
+ } catch {
289
+ /* already gone */
290
+ }
291
+ }
292
+ });
293
+
294
+ it("no PI_SUBAGENT_SOCKET → immediate cancelled response (unchanged behavior)", async () => {
295
+ bridgeState.active = false;
296
+ delete process.env.PI_SUBAGENT_SOCKET;
297
+
298
+ const tool = captureTool();
299
+ const result = await tool.execute("tool-2", makeParams(), undefined, undefined, { hasUI: false });
300
+
301
+ 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();
304
+ });
305
+ });
306
+
307
+ // ── TUI branch — unchanged except toolCallId on the ASK_REQUEST emit ────────
308
+
309
+ describe("TUI branch (bridge inactive, has UI)", () => {
310
+ it("emits ASK_REQUEST (main) with toolCallId and does NOT emit ASK_RESPONSE", async () => {
311
+ bridgeState.active = false;
312
+ vi.mocked(askSingleQuestionWithInlineNote).mockResolvedValue({ selectedOptions: ["A"] });
313
+
314
+ const tool = captureTool();
315
+ const result = await tool.execute("tool-3", makeParams(), undefined, undefined, { hasUI: true, ui: {} });
316
+
317
+ expect(emitted(Events.ASK_REQUEST).length).toBe(1);
318
+ const req = emitted(Events.ASK_REQUEST)[0]!;
319
+ expect(req.source).toBe("main");
320
+ expect(req.toolCallId).toBe("tool-3");
321
+ expect(typeof req.requestId).toBe("string");
322
+
323
+ // The TUI path settles the picker inline — no ASK_RESPONSE on the bus
324
+ expect(emitted(Events.ASK_RESPONSE).length).toBe(0);
325
+
326
+ expect(vi.mocked(askSingleQuestionWithInlineNote)).toHaveBeenCalledTimes(1);
327
+ expect(result.content[0]!.text).toContain("q1: A");
328
+ });
329
+
330
+ it("multi-question TUI path routes through the tabs dialog (unchanged)", async () => {
331
+ bridgeState.active = false;
332
+ vi.mocked(askQuestionsWithTabs).mockResolvedValue({
333
+ cancelled: false,
334
+ selections: [
335
+ { selectedOptions: ["A"] },
336
+ { selectedOptions: ["B"], customInput: "note" },
337
+ ],
338
+ });
339
+
340
+ const tool = captureTool();
341
+ const result = await tool.execute(
342
+ "tool-4",
343
+ {
344
+ questions: [
345
+ { id: "q1", question: "First?", options: [{ label: "A" }, { label: "B" }] },
346
+ { id: "q2", question: "Second?", multi: true, options: [{ label: "A" }, { label: "B" }] },
347
+ ],
348
+ },
349
+ undefined,
350
+ undefined,
351
+ { hasUI: true, ui: {} },
352
+ );
353
+
354
+ expect(vi.mocked(askQuestionsWithTabs)).toHaveBeenCalledTimes(1);
355
+ expect(emitted(Events.ASK_REQUEST).length).toBe(1);
356
+ expect(emitted(Events.ASK_RESPONSE).length).toBe(0);
357
+ expect(result.content[0]!.text).toContain("q1: A");
358
+ expect(result.content[0]!.text).toContain("q2: [B] + Other: \"note\"");
359
+ });
360
+
361
+ it("the empty-questions guard still returns the error text (before any bus emit)", async () => {
362
+ bridgeState.active = false;
363
+
364
+ const tool = captureTool();
365
+ const result = await tool.execute("tool-5", { questions: [] }, undefined, undefined, { hasUI: true, ui: {} });
366
+
367
+ expect(result.content[0]!.text).toBe("Error: questions must not be empty");
368
+ expect(busState.emit).not.toHaveBeenCalled();
369
+ // No dialog shown → no herdr pairing either
370
+ expect(herdrEmits()).toEqual([]);
371
+ });
372
+ });
373
+
374
+ // ── herdr pairing (pi.events 'herdr:blocked') ────────────────────────────
375
+ //
376
+ // herdr's managed extension refcounts the blocked state off this event
377
+ // (+1 on active:true, −1 on active:false; TUI-only). The tool must pair
378
+ // every active:true with exactly one active:false — on answer, cancel,
379
+ // AND error — or the pane is stuck at "blocked".
380
+
381
+ describe("herdr pairing (TUI)", () => {
382
+ it("single question, answered: active:true (label = the question) then active:false, in order", async () => {
383
+ bridgeState.active = false;
384
+ vi.mocked(askSingleQuestionWithInlineNote).mockResolvedValue({ selectedOptions: ["A"] });
385
+
386
+ const tool = captureTool();
387
+ await tool.execute("tool-7", makeParams(), undefined, undefined, { hasUI: true, ui: {} });
388
+
389
+ expect(herdrEmits()).toEqual([
390
+ { active: true, label: "Which option?" },
391
+ { active: false, label: undefined },
392
+ ]);
393
+ });
394
+
395
+ it("single question, cancelled (empty selection): still paired", async () => {
396
+ bridgeState.active = false;
397
+ vi.mocked(askSingleQuestionWithInlineNote).mockResolvedValue({ selectedOptions: [] });
398
+
399
+ const tool = captureTool();
400
+ const result = await tool.execute("tool-8", makeParams(), undefined, undefined, { hasUI: true, ui: {} });
401
+
402
+ expect(result.content[0]!.text).toContain("(cancelled)");
403
+ expect(herdrEmits()).toEqual([
404
+ { active: true, label: "Which option?" },
405
+ { active: false, label: undefined },
406
+ ]);
407
+ });
408
+
409
+ it("picker error: active:false still emitted (finally) and the error propagates", async () => {
410
+ bridgeState.active = false;
411
+ vi.mocked(askSingleQuestionWithInlineNote).mockRejectedValue(new Error("ui crashed"));
412
+
413
+ const tool = captureTool();
414
+ await expect(tool.execute("tool-9", makeParams(), undefined, undefined, { hasUI: true, ui: {} })).rejects.toThrow("ui crashed");
415
+
416
+ expect(herdrEmits()).toEqual([
417
+ { active: true, label: "Which option?" },
418
+ { active: false, label: undefined },
419
+ ]);
420
+ });
421
+
422
+ it("multi question: ONE pair, label = the first question", async () => {
423
+ bridgeState.active = false;
424
+ vi.mocked(askQuestionsWithTabs).mockResolvedValue({
425
+ cancelled: false,
426
+ selections: [{ selectedOptions: ["A"] }, { selectedOptions: ["B"] }],
427
+ });
428
+
429
+ const tool = captureTool();
430
+ await tool.execute(
431
+ "tool-10",
432
+ {
433
+ questions: [
434
+ { id: "q1", question: "First?", options: [{ label: "A" }] },
435
+ { id: "q2", question: "Second?", options: [{ label: "B" }] },
436
+ ],
437
+ },
438
+ undefined,
439
+ undefined,
440
+ { hasUI: true, ui: {} },
441
+ );
442
+
443
+ expect(herdrEmits()).toEqual([
444
+ { active: true, label: "First?" },
445
+ { active: false, label: undefined },
446
+ ]);
447
+ });
448
+
449
+ it("bridge path: NO herdr emit (non-TUI — herdr's handler is a no-op there)", async () => {
450
+ bridgeState.active = true;
451
+ bridgeState.ask.mockResolvedValue({ cancelled: false, results: [{ id: "q1", selectedOptions: ["A"] }] });
452
+
453
+ const tool = captureTool();
454
+ await tool.execute("tool-11", makeParams(), undefined, undefined, { hasUI: true, ui: {} });
455
+
456
+ expect(herdrEmits()).toEqual([]);
457
+ });
458
+
459
+ it("headless path: NO herdr emit (the child is non-TUI; the parent's relay reports it)", async () => {
460
+ bridgeState.active = false;
461
+ delete process.env.PI_SUBAGENT_SOCKET;
462
+
463
+ const tool = captureTool();
464
+ await tool.execute("tool-12", makeParams(), undefined, undefined, { hasUI: false });
465
+
466
+ expect(herdrEmits()).toEqual([]);
467
+ });
468
+ });
469
+
470
+ // ── ipc-relay: per-message bridge gate ───────────────────────────────────────
471
+
472
+ describe("ipc-relay", () => {
473
+ it("bridge active: a subagent ASK_REQUEST is NOT consumed (no picker, no ASK_RESPONSE)", async () => {
474
+ bridgeState.active = true;
475
+ const unsubscribes: Array<() => void> = [];
476
+ registerIpcRelay({ events: eventsState } as unknown as ExtensionAPI, () => ({ ui: {} } as unknown as ExtensionContext), unsubscribes);
477
+ relayUnsubs.push(...unsubscribes);
478
+
479
+ busState.emit(Events.ASK_REQUEST, {
480
+ source: "subagent:x",
481
+ requestId: "relay-1",
482
+ questions: [{ id: "q1", question: "Which?", options: [{ label: "A" }] }],
483
+ });
484
+ // The relay defers via setImmediate before handling — give it a moment
485
+ await new Promise((resolve) => setTimeout(resolve, 50));
486
+
487
+ expect(vi.mocked(askSingleQuestionWithInlineNote)).not.toHaveBeenCalled();
488
+ expect(vi.mocked(askQuestionsWithTabs)).not.toHaveBeenCalled();
489
+ expect(emitted(Events.ASK_RESPONSE).length).toBe(0);
490
+ // Not consumed → no dialog shown → no herdr pairing
491
+ expect(herdrEmits()).toEqual([]);
492
+ });
493
+
494
+ it("bridge inactive: a subagent ASK_REQUEST is consumed and answered via ASK_RESPONSE (existing behavior)", async () => {
495
+ bridgeState.active = false;
496
+ vi.mocked(askSingleQuestionWithInlineNote).mockResolvedValue({ selectedOptions: ["A"] });
497
+ const unsubscribes: Array<() => void> = [];
498
+ registerIpcRelay({ events: eventsState } as unknown as ExtensionAPI, () => ({ ui: {} } as unknown as ExtensionContext), unsubscribes);
499
+ relayUnsubs.push(...unsubscribes);
500
+
501
+ busState.emit(Events.ASK_REQUEST, {
502
+ source: "subagent:x",
503
+ requestId: "relay-2",
504
+ questions: [{ id: "q1", question: "Which?", options: [{ label: "A" }] }],
505
+ });
506
+ await vi.waitFor(() => {
507
+ expect(emitted(Events.ASK_RESPONSE).length).toBe(1);
508
+ });
509
+
510
+ const resp = emitted(Events.ASK_RESPONSE)[0]!;
511
+ expect(resp.requestId).toBe("relay-2");
512
+ expect(resp.cancelled).toBe(false);
513
+ expect(resp.results).toEqual([{ id: "q1", selectedOptions: ["A"], customInput: undefined }]);
514
+ });
515
+
516
+ it("bridge inactive: the parent's dialog pairs a herdr blocked/unblocked (label = the subagent's question)", async () => {
517
+ bridgeState.active = false;
518
+ vi.mocked(askSingleQuestionWithInlineNote).mockResolvedValue({ selectedOptions: ["A"] });
519
+ const unsubscribes: Array<() => void> = [];
520
+ registerIpcRelay({ events: eventsState } as unknown as ExtensionAPI, () => ({ ui: {} } as unknown as ExtensionContext), unsubscribes);
521
+ relayUnsubs.push(...unsubscribes);
522
+
523
+ busState.emit(Events.ASK_REQUEST, {
524
+ source: "subagent:x",
525
+ requestId: "relay-3",
526
+ questions: [{ id: "q1", question: "Which?", options: [{ label: "A" }] }],
527
+ });
528
+ await vi.waitFor(() => {
529
+ expect(emitted(Events.ASK_RESPONSE).length).toBe(1);
530
+ });
531
+
532
+ // The PARENT's TUI is the surface the subagent's dialog appears on — the
533
+ // child process is headless and its own herdr extension is inert, so the
534
+ // pairing happens here, in the parent.
535
+ expect(herdrEmits()).toEqual([
536
+ { active: true, label: "Which?" },
537
+ { active: false, label: undefined },
538
+ ]);
539
+ });
540
+
541
+ it("bridge inactive, picker error: the herdr pairing still settles (the catch converts it to a cancelled response)", async () => {
542
+ bridgeState.active = false;
543
+ vi.mocked(askSingleQuestionWithInlineNote).mockRejectedValue(new Error("ui crashed"));
544
+ const unsubscribes: Array<() => void> = [];
545
+ registerIpcRelay({ events: eventsState } as unknown as ExtensionAPI, () => ({ ui: {} } as unknown as ExtensionContext), unsubscribes);
546
+ relayUnsubs.push(...unsubscribes);
547
+
548
+ busState.emit(Events.ASK_REQUEST, {
549
+ source: "subagent:x",
550
+ requestId: "relay-4",
551
+ questions: [{ id: "q1", question: "Which?", options: [{ label: "A" }] }],
552
+ });
553
+ await vi.waitFor(() => {
554
+ expect(emitted(Events.ASK_RESPONSE).length).toBe(1);
555
+ });
556
+
557
+ const resp = emitted(Events.ASK_RESPONSE)[0]!;
558
+ expect(resp.cancelled).toBe(true);
559
+ expect(herdrEmits()).toEqual([
560
+ { active: true, label: "Which?" },
561
+ { active: false, label: undefined },
562
+ ]);
563
+ });
564
+ });
package/src/tool.ts CHANGED
@@ -1,11 +1,14 @@
1
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
1
+ import type { AgentToolResult, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { Type, type Static } from "typebox";
3
3
  import { getBus, Events } from "@pi-archimedes/core/bus";
4
+ import { getBridge } from "@pi-archimedes/core/bridge";
4
5
  import { connect } from "node:net";
5
6
  import { randomUUID } from "node:crypto";
6
7
  import { OTHER_OPTION, type AskQuestion } from "./selection.js";
7
8
  import { askSingleQuestionWithInlineNote } from "./picker.js";
8
9
  import { askQuestionsWithTabs } from "./dialog.js";
10
+ import { renderAskCall, renderAskResult } from "./renderer.js";
11
+ import { emitBlocked, blockedLabel } from "./herdr.js";
9
12
 
10
13
  // ── Schemas ──────────────────────────────────────────────────────────────────
11
14
 
@@ -107,7 +110,7 @@ function toSessionSafeQuestionResult(result: QuestionResult): QuestionResult {
107
110
  };
108
111
  }
109
112
 
110
- function formatSelectionForSummary(result: QuestionResult): string {
113
+ export function formatSelectionForSummary(result: QuestionResult): string {
111
114
  const hasSelectedOptions = result.selectedOptions.length > 0;
112
115
  const hasCustomInput = Boolean(result.customInput);
113
116
 
@@ -183,6 +186,28 @@ export function buildAskSessionContent(results: QuestionResult[]): string {
183
186
  return `User answers:\n${summaryLines}\n\nAnswer context:\n${contextBlocks}`;
184
187
  }
185
188
 
189
+ /**
190
+ * Build QuestionResult[] from an AskResponsePayload-shaped response. Shared by
191
+ * the bridge branch and the child (headless) branch.
192
+ */
193
+ function responseToResults(
194
+ response: { cancelled: boolean; results: Array<{ id: string; selectedOptions: string[]; customInput?: string }> },
195
+ questions: AskParams["questions"],
196
+ ): QuestionResult[] {
197
+ return questions.map((q, i) => {
198
+ const r = response.results[i];
199
+ return {
200
+ id: q.id,
201
+ question: q.question,
202
+ description: q.description && q.description.trim().length > 0 ? q.description : undefined,
203
+ options: q.options.map((o) => o.label),
204
+ multi: q.multi ?? false,
205
+ selectedOptions: r?.selectedOptions ?? [],
206
+ customInput: r?.customInput ?? undefined,
207
+ };
208
+ });
209
+ }
210
+
186
211
  // ── Tool description ─────────────────────────────────────────────────────────
187
212
 
188
213
  const ASK_TOOL_DESCRIPTION = `
@@ -205,8 +230,49 @@ export function registerAskTool(pi: ExtensionAPI): void {
205
230
  label: "Ask",
206
231
  description: ASK_TOOL_DESCRIPTION,
207
232
  parameters: AskParamsSchema,
233
+ renderCall: renderAskCall,
234
+ renderResult: renderAskResult,
208
235
 
209
236
  async execute(_toolCallId, params: AskParams, _signal, _onUpdate, ctx) {
237
+ // Bridge mode (checked first): the root's Client request is sent by
238
+ // bridge.ask() directly — NOT by the relay/bridge bus subscription.
239
+ // The bus events are notify + refcount only, and the pairing
240
+ // ASK_RESPONSE fires on ALL paths (success, error, timeout) so the
241
+ // refcount never leaks and the state machine is not stuck at blocked.
242
+ if (getBridge().active) {
243
+ const requestId = randomUUID();
244
+ getBus().emit(Events.ASK_REQUEST, {
245
+ source: "main",
246
+ requestId,
247
+ questions: params.questions,
248
+ toolCallId: _toolCallId,
249
+ });
250
+
251
+ let response: { cancelled: boolean; results: Array<{ id: string; selectedOptions: string[]; customInput?: string }> };
252
+ try {
253
+ // Pass the turn's AbortSignal: if the user aborts while the Client
254
+ // modal is open, the bridge request is cancelled (instead of
255
+ // lingering up to the 5-minute timeout) and the paired cancelled
256
+ // ASK_RESPONSE keeps the refcount balanced.
257
+ response = await getBridge().ask(params, _toolCallId, _signal);
258
+ } catch {
259
+ response = { cancelled: true, results: params.questions.map((q) => ({ id: q.id, selectedOptions: [] })) };
260
+ }
261
+ getBus().emit(Events.ASK_RESPONSE, { requestId, cancelled: response.cancelled, results: response.results });
262
+
263
+ const results = responseToResults(response, params.questions);
264
+ if (response.cancelled && results.every((r) => r.selectedOptions.length === 0)) {
265
+ return {
266
+ content: [{ type: "text", text: "User cancelled the question." }],
267
+ details: { results, customInput: undefined, description: undefined } satisfies AskToolDetails,
268
+ };
269
+ }
270
+ return {
271
+ content: [{ type: "text", text: buildAskSessionContent(results) }],
272
+ details: { results, customInput: undefined, description: undefined } satisfies AskToolDetails,
273
+ };
274
+ }
275
+
210
276
  // Headless/subagent path: the child's ask tool connects to the parent's
211
277
  // PI_SUBAGENT_SOCKET bridge (created in subagent/spawn.ts:startAskSocketServer)
212
278
  // and exchanges ask_request/ask_response JSON lines.
@@ -237,6 +303,7 @@ export function registerAskTool(pi: ExtensionAPI): void {
237
303
  type: "ask_request",
238
304
  requestId,
239
305
  questions: params.questions,
306
+ toolCallId: _toolCallId,
240
307
  }) + "\n");
241
308
  });
242
309
 
@@ -272,19 +339,8 @@ export function registerAskTool(pi: ExtensionAPI): void {
272
339
  timer.unref();
273
340
  });
274
341
 
275
- // Build results from response
276
- const results: QuestionResult[] = params.questions.map((q, i) => {
277
- const r = response.results[i];
278
- return {
279
- id: q.id,
280
- question: q.question,
281
- description: q.description && q.description.trim().length > 0 ? q.description : undefined,
282
- options: q.options.map((o) => o.label),
283
- multi: q.multi ?? false,
284
- selectedOptions: r?.selectedOptions ?? [],
285
- customInput: r?.customInput ?? undefined,
286
- };
287
- });
342
+ // Build results from response (shared with the bridge branch)
343
+ const results = responseToResults(response, params.questions);
288
344
 
289
345
  if (response.cancelled && results.every((r) => r.selectedOptions.length === 0)) {
290
346
  return {
@@ -307,64 +363,86 @@ export function registerAskTool(pi: ExtensionAPI): void {
307
363
  }
308
364
 
309
365
  // Emit bus event so notify (if installed) can schedule a desktop notification
310
- getBus().emit(Events.ASK_REQUEST, { source: "main", requestId: randomUUID(), questions: params.questions });
311
-
312
- if (params.questions.length === 1) {
313
- const q = params.questions[0]!;
314
- const selection = q.multi
315
- ? (await askQuestionsWithTabs(ctx.ui, [q as AskQuestion])).selections[0] ?? { selectedOptions: [] }
316
- : await askSingleQuestionWithInlineNote(ctx.ui, q as AskQuestion);
317
- const optionLabels = q.options.map((option) => option.label);
318
- const desc = q.description && q.description.trim().length > 0 ? q.description : undefined;
319
-
320
- const result: QuestionResult = {
321
- id: q.id,
322
- question: q.question,
323
- description: desc,
324
- options: optionLabels,
325
- multi: q.multi ?? false,
326
- selectedOptions: selection.selectedOptions,
327
- customInput: selection.customInput,
328
- };
329
-
330
- const details: AskToolDetails = {
331
- id: q.id,
332
- question: q.question,
333
- description: desc,
334
- options: optionLabels,
335
- multi: q.multi ?? false,
336
- selectedOptions: selection.selectedOptions,
337
- customInput: selection.customInput,
338
- results: [result],
339
- };
340
-
341
- return {
342
- content: [{ type: "text", text: buildAskSessionContent([result]) }],
343
- details,
344
- };
345
- }
346
-
347
- const results: QuestionResult[] = [];
348
- const tabResult = await askQuestionsWithTabs(ctx.ui, params.questions as AskQuestion[]);
349
- for (let i = 0; i < params.questions.length; i++) {
350
- const q = params.questions[i]!;
351
- const selection = tabResult.selections[i] ?? { selectedOptions: [] };
352
- const desc = q.description && q.description.trim().length > 0 ? q.description : undefined;
353
- results.push({
354
- id: q.id,
355
- question: q.question,
356
- description: desc,
357
- options: q.options.map((option) => option.label),
358
- multi: q.multi ?? false,
359
- selectedOptions: selection.selectedOptions,
360
- customInput: selection.customInput,
361
- });
366
+ getBus().emit(Events.ASK_REQUEST, { source: "main", requestId: randomUUID(), questions: params.questions, toolCallId: _toolCallId });
367
+
368
+ // Report to herdr's managed extension (inert unless herdr spawned pi — the
369
+ // event is a no-op otherwise). One pair per dialog; the finally pairs it on
370
+ // answer, cancel, AND error so the refcount never leaks. (The bridge and
371
+ // headless paths above are non-TUI — herdr's handler is TUI-only, so they
372
+ // are intentionally not reported.)
373
+ emitBlocked(pi, true, blockedLabel(params.questions));
374
+ try {
375
+ return await executeTuiAsk(params, ctx);
376
+ } finally {
377
+ emitBlocked(pi, false);
362
378
  }
363
-
364
- return {
365
- content: [{ type: "text", text: buildAskSessionContent(results) }],
366
- details: { results, customInput: undefined, description: undefined } satisfies AskToolDetails,
367
- };
368
379
  },
369
380
  });
370
381
  }
382
+
383
+ /**
384
+ * Show the questions in the TUI (picker for a single non-multi question, tabs
385
+ * dialog otherwise) and build the tool result. The caller wraps this in the
386
+ * herdr blocked pairing (emitBlocked true before / false in finally).
387
+ */
388
+ async function executeTuiAsk(
389
+ params: AskParams,
390
+ ctx: ExtensionContext,
391
+ ): Promise<AgentToolResult<AskToolDetails>> {
392
+ if (params.questions.length === 1) {
393
+ const q = params.questions[0]!;
394
+ const selection = q.multi
395
+ ? (await askQuestionsWithTabs(ctx.ui, [q as AskQuestion])).selections[0] ?? { selectedOptions: [] }
396
+ : await askSingleQuestionWithInlineNote(ctx.ui, q as AskQuestion);
397
+ const optionLabels = q.options.map((option) => option.label);
398
+ const desc = q.description && q.description.trim().length > 0 ? q.description : undefined;
399
+
400
+ const result: QuestionResult = {
401
+ id: q.id,
402
+ question: q.question,
403
+ description: desc,
404
+ options: optionLabels,
405
+ multi: q.multi ?? false,
406
+ selectedOptions: selection.selectedOptions,
407
+ customInput: selection.customInput,
408
+ };
409
+
410
+ const details: AskToolDetails = {
411
+ id: q.id,
412
+ question: q.question,
413
+ description: desc,
414
+ options: optionLabels,
415
+ multi: q.multi ?? false,
416
+ selectedOptions: selection.selectedOptions,
417
+ customInput: selection.customInput,
418
+ results: [result],
419
+ };
420
+
421
+ return {
422
+ content: [{ type: "text", text: buildAskSessionContent([result]) }],
423
+ details,
424
+ };
425
+ }
426
+
427
+ const results: QuestionResult[] = [];
428
+ const tabResult = await askQuestionsWithTabs(ctx.ui, params.questions as AskQuestion[]);
429
+ for (let i = 0; i < params.questions.length; i++) {
430
+ const q = params.questions[i]!;
431
+ const selection = tabResult.selections[i] ?? { selectedOptions: [] };
432
+ const desc = q.description && q.description.trim().length > 0 ? q.description : undefined;
433
+ results.push({
434
+ id: q.id,
435
+ question: q.question,
436
+ description: desc,
437
+ options: q.options.map((option) => option.label),
438
+ multi: q.multi ?? false,
439
+ selectedOptions: selection.selectedOptions,
440
+ customInput: selection.customInput,
441
+ });
442
+ }
443
+
444
+ return {
445
+ content: [{ type: "text", text: buildAskSessionContent(results) }],
446
+ details: { results, customInput: undefined, description: undefined } satisfies AskToolDetails,
447
+ };
448
+ }