@pi-archimedes/ask 2.7.3 → 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 +2 -2
- package/src/ipc-relay.ts +8 -0
- package/src/tool.test.ts +392 -0
- package/src/tool.ts +66 -14
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-archimedes/ask",
|
|
3
|
-
"version": "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.
|
|
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
|
package/src/tool.test.ts
ADDED
|
@@ -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
|
|
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]!;
|