@pi-archimedes/ask 2.7.2 → 2.8.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.2",
3
+ "version": "2.8.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.2"
18
+ "@pi-archimedes/core": "2.8.0"
19
19
  },
20
20
  "peerDependencies": {
21
21
  "@earendil-works/pi-coding-agent": ">=0.1.0",
package/src/ipc-relay.ts CHANGED
@@ -1,5 +1,6 @@
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";
@@ -19,6 +20,13 @@ export function registerIpcRelay(
19
20
  // Skip main agent — it shows its own UI in the tool handler
20
21
  if (data.source === "main") return;
21
22
 
23
+ // Bridge mode: the bridge forwards subagent asks to the Client — the
24
+ // relay must stay inert (per-message gate: registerIpcRelay runs at
25
+ // extension load when bridge.active is false in every mode, and ctx.mode
26
+ // is only captured at the first session_start, so a registration-time
27
+ // gate would silently reintroduce the double-consumption bug).
28
+ if (getBridge().active) return;
29
+
22
30
  if (!data.questions || data.questions.length === 0) return;
23
31
 
24
32
  // Defer to next tick so TUI can process current state
@@ -0,0 +1,392 @@
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
+
12
+ // ── Mocks ────────────────────────────────────────────────────────────────────
13
+ //
14
+ // Bus: an in-memory pub/sub whose `emit` is a spy (records + dispatches), so
15
+ // tests can assert on emitted payloads while still letting the relay's
16
+ // `on(ASK_REQUEST)` subscription receive them.
17
+ // Bridge: a mutable state object — `active` flips per test, `ask` is a vi.fn.
18
+ // Picker/dialog: mocked so the TUI branch never touches the real TUI.
19
+
20
+ const { busState, bridgeState } = vi.hoisted(() => {
21
+ const listeners: Record<string, Array<(p: unknown) => void>> = {};
22
+ const emit = vi.fn((event: string, payload: unknown) => {
23
+ for (const fn of listeners[event] ?? []) {
24
+ Promise.resolve(fn(payload)).catch(() => {
25
+ /* listener errors are not under test here */
26
+ });
27
+ }
28
+ });
29
+ const on = (event: string, fn: (p: unknown) => void) => {
30
+ (listeners[event] ??= []).push(fn);
31
+ return () => {
32
+ const subs = listeners[event] ?? [];
33
+ const i = subs.indexOf(fn);
34
+ if (i !== -1) subs.splice(i, 1);
35
+ };
36
+ };
37
+ const ask = vi.fn();
38
+ return {
39
+ busState: { listeners, emit, on },
40
+ bridgeState: {
41
+ active: false,
42
+ ask,
43
+ confirm: vi.fn(),
44
+ password: vi.fn(),
45
+ state: vi.fn(() => "idle"),
46
+ },
47
+ };
48
+ });
49
+
50
+ vi.mock("@pi-archimedes/core/bus", async (importOriginal) => {
51
+ const actual = await importOriginal() as typeof import("@pi-archimedes/core/bus");
52
+ return { ...actual, getBus: () => ({ emit: busState.emit, on: busState.on }) };
53
+ });
54
+
55
+ vi.mock("@pi-archimedes/core/bridge", () => ({
56
+ getBridge: () => bridgeState,
57
+ }));
58
+
59
+ vi.mock("./picker.js", () => ({
60
+ askSingleQuestionWithInlineNote: vi.fn(),
61
+ }));
62
+
63
+ vi.mock("./dialog.js", () => ({
64
+ askQuestionsWithTabs: vi.fn(),
65
+ }));
66
+
67
+ const { askSingleQuestionWithInlineNote } = await import("./picker.js");
68
+ const { askQuestionsWithTabs } = await import("./dialog.js");
69
+
70
+ // ── Helpers ──────────────────────────────────────────────────────────────────
71
+
72
+ function captureTool(): {
73
+ execute: (
74
+ id: string,
75
+ params: { questions: unknown[] },
76
+ signal: unknown,
77
+ onUpdate: unknown,
78
+ ctx: unknown,
79
+ ) => Promise<{ content: Array<{ type: string; text: string }>; details: unknown }>;
80
+ } {
81
+ const registered: Array<Record<string, unknown>> = [];
82
+ registerAskTool({ registerTool: (t: Record<string, unknown>) => registered.push(t) } as unknown as ExtensionAPI);
83
+ return registered[0] as ReturnType<typeof captureTool>;
84
+ }
85
+
86
+ function makeParams(): { questions: unknown[] } {
87
+ return {
88
+ questions: [
89
+ {
90
+ id: "q1",
91
+ question: "Which option?",
92
+ description: "Some context",
93
+ options: [{ label: "A" }, { label: "B" }],
94
+ },
95
+ ],
96
+ };
97
+ }
98
+
99
+ /** The payloads of the recorded `emit` calls for the given event. */
100
+ function emitted(event: string): Record<string, unknown>[] {
101
+ return busState.emit.mock.calls
102
+ .filter((c) => c[0] === event)
103
+ .map((c) => c[1]!) as Record<string, unknown>[];
104
+ }
105
+
106
+ // Relay subscriptions are tracked file-wide and torn down in afterEach (not at
107
+ // the end of each test body) so a failing assertion cannot leak a live relay
108
+ // handler into the next test (double registration → double ASK_RESPONSE).
109
+ const relayUnsubs: Array<() => void> = [];
110
+
111
+ afterEach(() => {
112
+ relayUnsubs.splice(0).forEach((u) => u());
113
+ });
114
+
115
+ beforeEach(() => {
116
+ busState.emit.mockClear();
117
+ bridgeState.active = false;
118
+ bridgeState.ask.mockReset();
119
+ vi.mocked(askSingleQuestionWithInlineNote).mockReset();
120
+ vi.mocked(askQuestionsWithTabs).mockReset();
121
+ });
122
+
123
+ // ── Bridge branch (checked first) ────────────────────────────────────────────
124
+
125
+ describe("bridge branch", () => {
126
+ it("emits ASK_REQUEST (main) with toolCallId, then a paired ASK_RESPONSE, and returns the built results", async () => {
127
+ bridgeState.active = true;
128
+ bridgeState.ask.mockResolvedValue({
129
+ cancelled: false,
130
+ results: [{ id: "q1", selectedOptions: ["B"], customInput: "extra" }],
131
+ });
132
+
133
+ const tool = captureTool();
134
+ const result = await tool.execute("tool-1", makeParams(), undefined, undefined, { hasUI: true, ui: {} });
135
+
136
+ // bridge.ask was called with the params + toolCallId + the turn's AbortSignal
137
+ // (undefined here — the test passes no signal)
138
+ expect(bridgeState.ask).toHaveBeenCalledTimes(1);
139
+ expect(bridgeState.ask).toHaveBeenCalledWith({ questions: makeParams().questions }, "tool-1", undefined);
140
+
141
+ // ASK_REQUEST (main) with toolCallId
142
+ expect(emitted(Events.ASK_REQUEST).length).toBe(1);
143
+ const req = emitted(Events.ASK_REQUEST)[0]!;
144
+ expect(req.source).toBe("main");
145
+ expect(req.toolCallId).toBe("tool-1");
146
+ expect(req.questions).toEqual(makeParams().questions);
147
+ expect(typeof req.requestId).toBe("string");
148
+
149
+ // Paired ASK_RESPONSE (same requestId, the bridge's response verbatim)
150
+ expect(emitted(Events.ASK_RESPONSE).length).toBe(1);
151
+ const resp = emitted(Events.ASK_RESPONSE)[0]!;
152
+ expect(resp.requestId).toBe(req.requestId);
153
+ expect(resp.cancelled).toBe(false);
154
+ expect(resp.results).toEqual([{ id: "q1", selectedOptions: ["B"], customInput: "extra" }]);
155
+
156
+ // Built results in the returned content
157
+ expect(result.content[0]!.text).toContain("q1: B + Other: \"extra\"");
158
+ });
159
+
160
+ it("a rejected bridge.ask() still emits the paired ASK_RESPONSE (cancelled) and returns the cancel text", async () => {
161
+ bridgeState.active = true;
162
+ bridgeState.ask.mockRejectedValue(new Error("bridge is not active"));
163
+
164
+ const tool = captureTool();
165
+ const result = await tool.execute("tool-1", makeParams(), undefined, undefined, { hasUI: true, ui: {} });
166
+
167
+ expect(emitted(Events.ASK_REQUEST).length).toBe(1);
168
+ expect(emitted(Events.ASK_RESPONSE).length).toBe(1);
169
+ const resp = emitted(Events.ASK_RESPONSE)[0]!;
170
+ expect(resp.requestId).toBe(emitted(Events.ASK_REQUEST)[0]!.requestId);
171
+ expect(resp.cancelled).toBe(true);
172
+ expect(resp.results).toEqual([{ id: "q1", selectedOptions: [] }]);
173
+ expect(result.content[0]!.text).toBe("User cancelled the question.");
174
+ });
175
+
176
+ it("a pre-aborted signal → cancelled content + paired cancelled ASK_RESPONSE", async () => {
177
+ bridgeState.active = true;
178
+ // Simulate the cancel-rejection: when the turn's signal is already aborted,
179
+ // bridge.ask() calls handle.cancel() directly (the abort event never fires),
180
+ // which settles the promise via the socket-close handler → a rejection.
181
+ bridgeState.ask.mockRejectedValue(new Error("bridge channel closed before response"));
182
+
183
+ const controller = new AbortController();
184
+ controller.abort(); // pre-aborted BEFORE execute() is invoked
185
+
186
+ const tool = captureTool();
187
+ const result = await tool.execute("tool-6", makeParams(), controller.signal, undefined, { hasUI: true, ui: {} });
188
+
189
+ // bridge.ask was called with the pre-aborted signal (3rd arg)
190
+ expect(bridgeState.ask).toHaveBeenCalledTimes(1);
191
+ expect(bridgeState.ask).toHaveBeenCalledWith(makeParams(), "tool-6", controller.signal);
192
+
193
+ // Paired cancelled ASK_RESPONSE (the tool's catch converts the
194
+ // cancel-rejection into a cancelled response so the refcount stays balanced)
195
+ expect(emitted(Events.ASK_REQUEST).length).toBe(1);
196
+ expect(emitted(Events.ASK_RESPONSE).length).toBe(1);
197
+ const resp = emitted(Events.ASK_RESPONSE)[0]!;
198
+ expect(resp.requestId).toBe(emitted(Events.ASK_REQUEST)[0]!.requestId);
199
+ expect(resp.cancelled).toBe(true);
200
+ expect(resp.results).toEqual([{ id: "q1", selectedOptions: [] }]);
201
+
202
+ // Cancelled content
203
+ expect(result.content[0]!.text).toBe("User cancelled the question.");
204
+ });
205
+ });
206
+
207
+ // ── Child (headless) branch — unchanged except toolCallId on the socket payload ──
208
+
209
+ describe("child branch (bridge inactive, no UI)", () => {
210
+ it("writes ask_request with toolCallId to PI_SUBAGENT_SOCKET and returns the response results", async () => {
211
+ bridgeState.active = false;
212
+ const socketPath = path.join(os.tmpdir(), `ask-tool-test-${randomUUID()}.sock`);
213
+ const received: Array<Record<string, unknown>> = [];
214
+ const server = net.createServer((socket) => {
215
+ let buffer = "";
216
+ socket.on("data", (chunk: Buffer) => {
217
+ buffer += chunk.toString("utf-8");
218
+ const lines = buffer.split("\n");
219
+ buffer = lines.pop() ?? "";
220
+ for (const line of lines) {
221
+ const trimmed = line.trim();
222
+ if (!trimmed) continue;
223
+ try {
224
+ const msg = JSON.parse(trimmed) as Record<string, unknown>;
225
+ if (msg.type === "ask_request") {
226
+ received.push(msg);
227
+ socket.write(
228
+ JSON.stringify({
229
+ type: "ask_response",
230
+ requestId: msg.requestId,
231
+ cancelled: false,
232
+ results: [{ id: "q1", selectedOptions: ["A"], customInput: "from-parent" }],
233
+ }) + "\n",
234
+ );
235
+ }
236
+ } catch {
237
+ /* malformed — ignore */
238
+ }
239
+ }
240
+ });
241
+ socket.on("error", () => {
242
+ /* connection dropped */
243
+ });
244
+ });
245
+ await new Promise<void>((resolve) => server.listen(socketPath, resolve));
246
+ process.env.PI_SUBAGENT_SOCKET = socketPath;
247
+
248
+ try {
249
+ const tool = captureTool();
250
+ const result = await tool.execute("tool-2", makeParams(), undefined, undefined, { hasUI: false });
251
+
252
+ expect(received.length).toBe(1);
253
+ expect(received[0]!.type).toBe("ask_request");
254
+ expect(received[0]!.toolCallId).toBe("tool-2");
255
+ expect(received[0]!.questions).toEqual(makeParams().questions);
256
+ expect(typeof received[0]!.requestId).toBe("string");
257
+
258
+ // The response results are built into the returned content
259
+ expect(result.content[0]!.text).toContain("q1: A + Other: \"from-parent\"");
260
+ } finally {
261
+ delete process.env.PI_SUBAGENT_SOCKET;
262
+ server.close();
263
+ try {
264
+ fs.unlinkSync(socketPath);
265
+ } catch {
266
+ /* already gone */
267
+ }
268
+ }
269
+ });
270
+
271
+ it("no PI_SUBAGENT_SOCKET → immediate cancelled response (unchanged behavior)", async () => {
272
+ bridgeState.active = false;
273
+ delete process.env.PI_SUBAGENT_SOCKET;
274
+
275
+ const tool = captureTool();
276
+ const result = await tool.execute("tool-2", makeParams(), undefined, undefined, { hasUI: false });
277
+
278
+ expect(result.content[0]!.text).toBe("User cancelled the question.");
279
+ // The child branch does not emit on the bus
280
+ expect(busState.emit).not.toHaveBeenCalled();
281
+ });
282
+ });
283
+
284
+ // ── TUI branch — unchanged except toolCallId on the ASK_REQUEST emit ────────
285
+
286
+ describe("TUI branch (bridge inactive, has UI)", () => {
287
+ it("emits ASK_REQUEST (main) with toolCallId and does NOT emit ASK_RESPONSE", async () => {
288
+ bridgeState.active = false;
289
+ vi.mocked(askSingleQuestionWithInlineNote).mockResolvedValue({ selectedOptions: ["A"] });
290
+
291
+ const tool = captureTool();
292
+ const result = await tool.execute("tool-3", makeParams(), undefined, undefined, { hasUI: true, ui: {} });
293
+
294
+ expect(emitted(Events.ASK_REQUEST).length).toBe(1);
295
+ const req = emitted(Events.ASK_REQUEST)[0]!;
296
+ expect(req.source).toBe("main");
297
+ expect(req.toolCallId).toBe("tool-3");
298
+ expect(typeof req.requestId).toBe("string");
299
+
300
+ // The TUI path settles the picker inline — no ASK_RESPONSE on the bus
301
+ expect(emitted(Events.ASK_RESPONSE).length).toBe(0);
302
+
303
+ expect(vi.mocked(askSingleQuestionWithInlineNote)).toHaveBeenCalledTimes(1);
304
+ expect(result.content[0]!.text).toContain("q1: A");
305
+ });
306
+
307
+ it("multi-question TUI path routes through the tabs dialog (unchanged)", async () => {
308
+ bridgeState.active = false;
309
+ vi.mocked(askQuestionsWithTabs).mockResolvedValue({
310
+ cancelled: false,
311
+ selections: [
312
+ { selectedOptions: ["A"] },
313
+ { selectedOptions: ["B"], customInput: "note" },
314
+ ],
315
+ });
316
+
317
+ const tool = captureTool();
318
+ const result = await tool.execute(
319
+ "tool-4",
320
+ {
321
+ questions: [
322
+ { id: "q1", question: "First?", options: [{ label: "A" }, { label: "B" }] },
323
+ { id: "q2", question: "Second?", multi: true, options: [{ label: "A" }, { label: "B" }] },
324
+ ],
325
+ },
326
+ undefined,
327
+ undefined,
328
+ { hasUI: true, ui: {} },
329
+ );
330
+
331
+ expect(vi.mocked(askQuestionsWithTabs)).toHaveBeenCalledTimes(1);
332
+ expect(emitted(Events.ASK_REQUEST).length).toBe(1);
333
+ expect(emitted(Events.ASK_RESPONSE).length).toBe(0);
334
+ expect(result.content[0]!.text).toContain("q1: A");
335
+ expect(result.content[0]!.text).toContain("q2: [B] + Other: \"note\"");
336
+ });
337
+
338
+ it("the empty-questions guard still returns the error text (before any bus emit)", async () => {
339
+ bridgeState.active = false;
340
+
341
+ const tool = captureTool();
342
+ const result = await tool.execute("tool-5", { questions: [] }, undefined, undefined, { hasUI: true, ui: {} });
343
+
344
+ expect(result.content[0]!.text).toBe("Error: questions must not be empty");
345
+ expect(busState.emit).not.toHaveBeenCalled();
346
+ });
347
+ });
348
+
349
+ // ── ipc-relay: per-message bridge gate ───────────────────────────────────────
350
+
351
+ describe("ipc-relay", () => {
352
+ it("bridge active: a subagent ASK_REQUEST is NOT consumed (no picker, no ASK_RESPONSE)", async () => {
353
+ bridgeState.active = true;
354
+ const unsubscribes: Array<() => void> = [];
355
+ registerIpcRelay({} as unknown as ExtensionAPI, () => ({ ui: {} } as unknown as ExtensionContext), unsubscribes);
356
+ relayUnsubs.push(...unsubscribes);
357
+
358
+ busState.emit(Events.ASK_REQUEST, {
359
+ source: "subagent:x",
360
+ requestId: "relay-1",
361
+ questions: [{ id: "q1", question: "Which?", options: [{ label: "A" }] }],
362
+ });
363
+ // The relay defers via setImmediate before handling — give it a moment
364
+ await new Promise((resolve) => setTimeout(resolve, 50));
365
+
366
+ expect(vi.mocked(askSingleQuestionWithInlineNote)).not.toHaveBeenCalled();
367
+ expect(vi.mocked(askQuestionsWithTabs)).not.toHaveBeenCalled();
368
+ expect(emitted(Events.ASK_RESPONSE).length).toBe(0);
369
+ });
370
+
371
+ it("bridge inactive: a subagent ASK_REQUEST is consumed and answered via ASK_RESPONSE (existing behavior)", async () => {
372
+ bridgeState.active = false;
373
+ vi.mocked(askSingleQuestionWithInlineNote).mockResolvedValue({ selectedOptions: ["A"] });
374
+ const unsubscribes: Array<() => void> = [];
375
+ registerIpcRelay({} as unknown as ExtensionAPI, () => ({ ui: {} } as unknown as ExtensionContext), unsubscribes);
376
+ relayUnsubs.push(...unsubscribes);
377
+
378
+ busState.emit(Events.ASK_REQUEST, {
379
+ source: "subagent:x",
380
+ requestId: "relay-2",
381
+ questions: [{ id: "q1", question: "Which?", options: [{ label: "A" }] }],
382
+ });
383
+ await vi.waitFor(() => {
384
+ expect(emitted(Events.ASK_RESPONSE).length).toBe(1);
385
+ });
386
+
387
+ const resp = emitted(Events.ASK_RESPONSE)[0]!;
388
+ expect(resp.requestId).toBe("relay-2");
389
+ expect(resp.cancelled).toBe(false);
390
+ expect(resp.results).toEqual([{ id: "q1", selectedOptions: ["A"], customInput: undefined }]);
391
+ });
392
+ });
package/src/tool.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { ExtensionAPI } 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";
@@ -183,6 +184,28 @@ export function buildAskSessionContent(results: QuestionResult[]): string {
183
184
  return `User answers:\n${summaryLines}\n\nAnswer context:\n${contextBlocks}`;
184
185
  }
185
186
 
187
+ /**
188
+ * Build QuestionResult[] from an AskResponsePayload-shaped response. Shared by
189
+ * the bridge branch and the child (headless) branch.
190
+ */
191
+ function responseToResults(
192
+ response: { cancelled: boolean; results: Array<{ id: string; selectedOptions: string[]; customInput?: string }> },
193
+ questions: AskParams["questions"],
194
+ ): QuestionResult[] {
195
+ return questions.map((q, i) => {
196
+ const r = response.results[i];
197
+ return {
198
+ id: q.id,
199
+ question: q.question,
200
+ description: q.description && q.description.trim().length > 0 ? q.description : undefined,
201
+ options: q.options.map((o) => o.label),
202
+ multi: q.multi ?? false,
203
+ selectedOptions: r?.selectedOptions ?? [],
204
+ customInput: r?.customInput ?? undefined,
205
+ };
206
+ });
207
+ }
208
+
186
209
  // ── Tool description ─────────────────────────────────────────────────────────
187
210
 
188
211
  const ASK_TOOL_DESCRIPTION = `
@@ -207,6 +230,45 @@ export function registerAskTool(pi: ExtensionAPI): void {
207
230
  parameters: AskParamsSchema,
208
231
 
209
232
  async execute(_toolCallId, params: AskParams, _signal, _onUpdate, ctx) {
233
+ // Bridge mode (checked first): the root's Client request is sent by
234
+ // bridge.ask() directly — NOT by the relay/bridge bus subscription.
235
+ // The bus events are notify + refcount only, and the pairing
236
+ // ASK_RESPONSE fires on ALL paths (success, error, timeout) so the
237
+ // refcount never leaks and the state machine is not stuck at blocked.
238
+ if (getBridge().active) {
239
+ const requestId = randomUUID();
240
+ getBus().emit(Events.ASK_REQUEST, {
241
+ source: "main",
242
+ requestId,
243
+ questions: params.questions,
244
+ toolCallId: _toolCallId,
245
+ });
246
+
247
+ let response: { cancelled: boolean; results: Array<{ id: string; selectedOptions: string[]; customInput?: string }> };
248
+ try {
249
+ // Pass the turn's AbortSignal: if the user aborts while the Client
250
+ // modal is open, the bridge request is cancelled (instead of
251
+ // lingering up to the 5-minute timeout) and the paired cancelled
252
+ // ASK_RESPONSE keeps the refcount balanced.
253
+ response = await getBridge().ask(params, _toolCallId, _signal);
254
+ } catch {
255
+ response = { cancelled: true, results: params.questions.map((q) => ({ id: q.id, selectedOptions: [] })) };
256
+ }
257
+ getBus().emit(Events.ASK_RESPONSE, { requestId, cancelled: response.cancelled, results: response.results });
258
+
259
+ const results = responseToResults(response, params.questions);
260
+ if (response.cancelled && results.every((r) => r.selectedOptions.length === 0)) {
261
+ return {
262
+ content: [{ type: "text", text: "User cancelled the question." }],
263
+ details: { results, customInput: undefined, description: undefined } satisfies AskToolDetails,
264
+ };
265
+ }
266
+ return {
267
+ content: [{ type: "text", text: buildAskSessionContent(results) }],
268
+ details: { results, customInput: undefined, description: undefined } satisfies AskToolDetails,
269
+ };
270
+ }
271
+
210
272
  // Headless/subagent path: the child's ask tool connects to the parent's
211
273
  // PI_SUBAGENT_SOCKET bridge (created in subagent/spawn.ts:startAskSocketServer)
212
274
  // and exchanges ask_request/ask_response JSON lines.
@@ -237,6 +299,7 @@ export function registerAskTool(pi: ExtensionAPI): void {
237
299
  type: "ask_request",
238
300
  requestId,
239
301
  questions: params.questions,
302
+ toolCallId: _toolCallId,
240
303
  }) + "\n");
241
304
  });
242
305
 
@@ -272,19 +335,8 @@ export function registerAskTool(pi: ExtensionAPI): void {
272
335
  timer.unref();
273
336
  });
274
337
 
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
- });
338
+ // Build results from response (shared with the bridge branch)
339
+ const results = responseToResults(response, params.questions);
288
340
 
289
341
  if (response.cancelled && results.every((r) => r.selectedOptions.length === 0)) {
290
342
  return {
@@ -307,7 +359,7 @@ export function registerAskTool(pi: ExtensionAPI): void {
307
359
  }
308
360
 
309
361
  // 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 });
362
+ getBus().emit(Events.ASK_REQUEST, { source: "main", requestId: randomUUID(), questions: params.questions, toolCallId: _toolCallId });
311
363
 
312
364
  if (params.questions.length === 1) {
313
365
  const q = params.questions[0]!;