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