@xynogen/pix-9router 0.3.4 → 0.3.5
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/README.md +4 -0
- package/package.json +4 -2
- package/src/fetch.ts +189 -79
- package/src/format.test.ts +316 -3
- package/src/render.test.ts +114 -25
- package/src/render.ts +49 -6
- package/src/search.ts +204 -99
- package/src/transcribe.test.ts +122 -6
- package/src/transcribe.ts +92 -10
package/README.md
CHANGED
|
@@ -37,6 +37,10 @@ export ROUTER_API_KEY="your-key-here"
|
|
|
37
37
|
- **fetch / search**: POST to `/web/fetch` and `/search` on the router (which proxies to exa). If the router is unreachable, falls back to `curl` (for `fetch`, raw URL fetch; for `search`, the same `/search` endpoint via curl). Tool output is rendered dimmed so fetched web content reads like faded context rather than primary output.
|
|
38
38
|
- **transcribe**: POST to `/audio/transcriptions` (Deepgram Nova 3 by default). Accepts any audio file path; falls back to curl.
|
|
39
39
|
|
|
40
|
+
## Compact results
|
|
41
|
+
|
|
42
|
+
Terminal tool results collapse after the shared Pix delay into metadata-driven rows while preserving all model-visible content. Examples include `✓ fetch https://example.com · 12.4K chars · markdown`, `⚡ search “query” · 8 results · curl fallback`, and `✓ transcribe meeting.mp3 · 12.4K chars · dg/nova-3`. Failures use `✗`; cancelled or fallback outcomes use `⚡`. Running and partial updates remain live, and expanding an elapsed row restores the normal dimmed content preview without restarting its timer. Configure the behavior with `collapse.delaySec` and the `fetch`, `search`, or `transcribe` entries under `collapse.tools` in `~/.pi/agent/pix.json`.
|
|
43
|
+
|
|
40
44
|
## Full distro
|
|
41
45
|
|
|
42
46
|
Source: [github.com/xynogen/pix-mono](https://github.com/xynogen/pix-mono)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xynogen/pix-9router",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.5",
|
|
4
4
|
"description": "Pi extension — 9Router provider + fetch/search tools via router API",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -39,10 +39,12 @@
|
|
|
39
39
|
},
|
|
40
40
|
"peerDependencies": {
|
|
41
41
|
"@earendil-works/pi-coding-agent": "*",
|
|
42
|
-
"@earendil-works/pi-ai": "*"
|
|
42
|
+
"@earendil-works/pi-ai": "*",
|
|
43
|
+
"@earendil-works/pi-tui": "*"
|
|
43
44
|
},
|
|
44
45
|
"dependencies": {
|
|
45
46
|
"@xynogen/pix-data": "^0.3.0",
|
|
47
|
+
"@xynogen/pix-pretty": "^1.7.21",
|
|
46
48
|
"typebox": "^1.1.38"
|
|
47
49
|
}
|
|
48
50
|
}
|
package/src/fetch.ts
CHANGED
|
@@ -15,6 +15,36 @@ import { makeRenderCall, makeRenderResult } from "./render.js";
|
|
|
15
15
|
|
|
16
16
|
const REQUEST_TIMEOUT_MS = 30_000;
|
|
17
17
|
|
|
18
|
+
type RouterOutcome = "running" | "fallback" | "success" | "cancelled" | "error";
|
|
19
|
+
type FetchFormat = "markdown" | "text" | "html";
|
|
20
|
+
|
|
21
|
+
export interface FetchResultDetails {
|
|
22
|
+
_type: "fetchResult";
|
|
23
|
+
outcome: RouterOutcome;
|
|
24
|
+
url: string;
|
|
25
|
+
format: FetchFormat;
|
|
26
|
+
source?: "api" | "curl-fallback" | "failed";
|
|
27
|
+
chars?: number;
|
|
28
|
+
truncated?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface FetchParams {
|
|
32
|
+
url: string;
|
|
33
|
+
format?: FetchFormat;
|
|
34
|
+
max_characters?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface FetchResult {
|
|
38
|
+
content: { type: "text"; text: string }[];
|
|
39
|
+
details: FetchResultDetails;
|
|
40
|
+
isError?: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface FetchDependencies {
|
|
44
|
+
apiPost: typeof apiPost;
|
|
45
|
+
curl: typeof curl;
|
|
46
|
+
}
|
|
47
|
+
|
|
18
48
|
function auth(): string | undefined {
|
|
19
49
|
return process.env.ROUTER_API_KEY;
|
|
20
50
|
}
|
|
@@ -101,6 +131,135 @@ export function formatFetchResult(raw: string): string {
|
|
|
101
131
|
return header.length > 0 ? `${header.join("\n")}\n\n${text}` : text;
|
|
102
132
|
}
|
|
103
133
|
|
|
134
|
+
function isCancelled(error: unknown, signal: AbortSignal | undefined): boolean {
|
|
135
|
+
return signal?.aborted === true || (error instanceof DOMException && error.name === "AbortError");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function executeFetch(
|
|
139
|
+
params: FetchParams,
|
|
140
|
+
signal?: AbortSignal,
|
|
141
|
+
onUpdate?: (result: FetchResult) => void,
|
|
142
|
+
dependencies: FetchDependencies = { apiPost, curl },
|
|
143
|
+
): Promise<FetchResult> {
|
|
144
|
+
const maxChars = params.max_characters ?? 1000;
|
|
145
|
+
const fmt = params.format ?? "markdown";
|
|
146
|
+
let apiMsg = "";
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
onUpdate?.({
|
|
150
|
+
content: [{ type: "text", text: `Fetching (${fmt}): ${params.url}...` }],
|
|
151
|
+
details: {
|
|
152
|
+
_type: "fetchResult",
|
|
153
|
+
outcome: "running",
|
|
154
|
+
url: params.url,
|
|
155
|
+
format: fmt,
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const raw = await dependencies.apiPost(
|
|
160
|
+
"/web/fetch",
|
|
161
|
+
{
|
|
162
|
+
model: "exa",
|
|
163
|
+
url: params.url,
|
|
164
|
+
format: fmt,
|
|
165
|
+
max_characters: maxChars,
|
|
166
|
+
},
|
|
167
|
+
signal,
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
const formatted = formatFetchResult(raw);
|
|
171
|
+
const truncated =
|
|
172
|
+
maxChars > 0 && formatted.length > maxChars
|
|
173
|
+
? `${formatted.slice(0, maxChars)}\n\n[truncated]`
|
|
174
|
+
: formatted;
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
content: [{ type: "text", text: truncated.slice(0, 20_000) }],
|
|
178
|
+
details: {
|
|
179
|
+
_type: "fetchResult",
|
|
180
|
+
outcome: "success",
|
|
181
|
+
url: params.url,
|
|
182
|
+
format: fmt,
|
|
183
|
+
source: "api",
|
|
184
|
+
chars: formatted.length,
|
|
185
|
+
truncated: maxChars > 0 && formatted.length > maxChars,
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
} catch (apiErr: unknown) {
|
|
189
|
+
apiMsg = apiErr instanceof Error ? apiErr.message : String(apiErr);
|
|
190
|
+
if (isCancelled(apiErr, signal)) {
|
|
191
|
+
return {
|
|
192
|
+
content: [{ type: "text", text: `Fetch cancelled.\nAPI: ${apiMsg}` }],
|
|
193
|
+
details: {
|
|
194
|
+
_type: "fetchResult",
|
|
195
|
+
outcome: "cancelled",
|
|
196
|
+
url: params.url,
|
|
197
|
+
format: fmt,
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
onUpdate?.({
|
|
202
|
+
content: [
|
|
203
|
+
{
|
|
204
|
+
type: "text",
|
|
205
|
+
text: `API failed: ${apiMsg}\nFalling back to curl...`,
|
|
206
|
+
},
|
|
207
|
+
],
|
|
208
|
+
details: {
|
|
209
|
+
_type: "fetchResult",
|
|
210
|
+
outcome: "running",
|
|
211
|
+
url: params.url,
|
|
212
|
+
format: fmt,
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
let html = await dependencies.curl(["-L", params.url]);
|
|
219
|
+
const chars = html.length;
|
|
220
|
+
const truncated = maxChars > 0 && html.length > maxChars;
|
|
221
|
+
if (truncated) html = `${html.slice(0, maxChars)}\n\n[truncated]`;
|
|
222
|
+
|
|
223
|
+
const banner =
|
|
224
|
+
"[FALLBACK — raw curl] API unavailable, content may differ from exa formatted output.";
|
|
225
|
+
const body =
|
|
226
|
+
fmt === "html"
|
|
227
|
+
? `${banner}\n\n${html.slice(0, 19_500)}`
|
|
228
|
+
: `${banner}\n\nRaw HTML (format='${fmt}' not applied):\n\n${html.slice(0, 19_000)}`;
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
content: [{ type: "text", text: body.slice(0, 20_000) }],
|
|
232
|
+
details: {
|
|
233
|
+
_type: "fetchResult",
|
|
234
|
+
outcome: "fallback",
|
|
235
|
+
url: params.url,
|
|
236
|
+
format: fmt,
|
|
237
|
+
source: "curl-fallback",
|
|
238
|
+
chars,
|
|
239
|
+
truncated,
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
} catch (curlErr: unknown) {
|
|
243
|
+
const curlMsg = curlErr instanceof Error ? curlErr.message : String(curlErr);
|
|
244
|
+
return {
|
|
245
|
+
content: [
|
|
246
|
+
{
|
|
247
|
+
type: "text",
|
|
248
|
+
text: `Fetch failed (both API and curl).\nAPI: ${apiMsg}\nCurl: ${curlMsg}`,
|
|
249
|
+
},
|
|
250
|
+
],
|
|
251
|
+
details: {
|
|
252
|
+
_type: "fetchResult",
|
|
253
|
+
outcome: "error",
|
|
254
|
+
url: params.url,
|
|
255
|
+
format: fmt,
|
|
256
|
+
source: "failed",
|
|
257
|
+
},
|
|
258
|
+
isError: true,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
104
263
|
export default function registerFetch(pi: ExtensionAPI): void {
|
|
105
264
|
pi.registerTool({
|
|
106
265
|
name: "fetch",
|
|
@@ -113,8 +272,35 @@ export default function registerFetch(pi: ExtensionAPI): void {
|
|
|
113
272
|
"Prefer format='markdown' for readable content, 'text' for plain text extraction, 'html' for raw source.",
|
|
114
273
|
"Set max_characters to cap response size (default 1000, 0 = unlimited). Use 5000-10000 for typical pages.",
|
|
115
274
|
],
|
|
116
|
-
renderCall: makeRenderCall("fetch", (args) =>
|
|
117
|
-
|
|
275
|
+
renderCall: makeRenderCall<unknown>("fetch", (args) =>
|
|
276
|
+
String((args as Partial<FetchParams>).url ?? ""),
|
|
277
|
+
),
|
|
278
|
+
renderResult: (
|
|
279
|
+
(renderTerminal) => (result, options, theme, context) =>
|
|
280
|
+
renderTerminal(result, options, theme, {
|
|
281
|
+
...context,
|
|
282
|
+
isError: context.isError && options.expanded,
|
|
283
|
+
})
|
|
284
|
+
)(
|
|
285
|
+
makeRenderResult<FetchResultDetails>({
|
|
286
|
+
tool: "fetch",
|
|
287
|
+
target: (details) => details.url,
|
|
288
|
+
meta: (details) => {
|
|
289
|
+
if (details.outcome === "error") return "failed";
|
|
290
|
+
if (details.outcome === "cancelled") return "cancelled";
|
|
291
|
+
const chars = `${details.chars ?? 0} chars`;
|
|
292
|
+
return details.outcome === "fallback"
|
|
293
|
+
? `${chars} · curl fallback`
|
|
294
|
+
: `${chars} · ${details.format}`;
|
|
295
|
+
},
|
|
296
|
+
status: (details) =>
|
|
297
|
+
details.outcome === "error"
|
|
298
|
+
? "error"
|
|
299
|
+
: details.outcome === "fallback" || details.outcome === "cancelled"
|
|
300
|
+
? "warning"
|
|
301
|
+
: "success",
|
|
302
|
+
}),
|
|
303
|
+
),
|
|
118
304
|
parameters: Type.Object({
|
|
119
305
|
url: Type.String({ description: "URL to fetch" }),
|
|
120
306
|
format: StringEnum(["markdown", "text", "html"] as const, {
|
|
@@ -130,83 +316,7 @@ export default function registerFetch(pi: ExtensionAPI): void {
|
|
|
130
316
|
}),
|
|
131
317
|
|
|
132
318
|
async execute(_toolCallId, params, signal, onUpdate) {
|
|
133
|
-
|
|
134
|
-
const fmt = params.format ?? "markdown";
|
|
135
|
-
let apiMsg = "";
|
|
136
|
-
|
|
137
|
-
try {
|
|
138
|
-
onUpdate?.({
|
|
139
|
-
content: [{ type: "text", text: `Fetching (${fmt}): ${params.url}...` }],
|
|
140
|
-
details: undefined,
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
const raw = await apiPost(
|
|
144
|
-
"/web/fetch",
|
|
145
|
-
{
|
|
146
|
-
model: "exa",
|
|
147
|
-
url: params.url,
|
|
148
|
-
format: fmt,
|
|
149
|
-
max_characters: maxChars,
|
|
150
|
-
},
|
|
151
|
-
signal,
|
|
152
|
-
);
|
|
153
|
-
|
|
154
|
-
const formatted = formatFetchResult(raw);
|
|
155
|
-
const truncated =
|
|
156
|
-
maxChars > 0 && formatted.length > maxChars
|
|
157
|
-
? `${formatted.slice(0, maxChars)}\n\n[truncated]`
|
|
158
|
-
: formatted;
|
|
159
|
-
|
|
160
|
-
return {
|
|
161
|
-
content: [{ type: "text", text: truncated.slice(0, 20_000) }],
|
|
162
|
-
details: {
|
|
163
|
-
source: "api",
|
|
164
|
-
chars: formatted.length,
|
|
165
|
-
truncated: maxChars > 0 && formatted.length > maxChars,
|
|
166
|
-
},
|
|
167
|
-
};
|
|
168
|
-
} catch (apiErr: unknown) {
|
|
169
|
-
apiMsg = apiErr instanceof Error ? apiErr.message : String(apiErr);
|
|
170
|
-
onUpdate?.({
|
|
171
|
-
content: [
|
|
172
|
-
{
|
|
173
|
-
type: "text",
|
|
174
|
-
text: `API failed: ${apiMsg}\nFalling back to curl...`,
|
|
175
|
-
},
|
|
176
|
-
],
|
|
177
|
-
details: undefined,
|
|
178
|
-
});
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
try {
|
|
182
|
-
let html = await curl(["-L", params.url]);
|
|
183
|
-
if (maxChars > 0 && html.length > maxChars)
|
|
184
|
-
html = `${html.slice(0, maxChars)}\n\n[truncated]`;
|
|
185
|
-
|
|
186
|
-
const banner =
|
|
187
|
-
"[FALLBACK — raw curl] API unavailable, content may differ from exa formatted output.";
|
|
188
|
-
const body =
|
|
189
|
-
fmt === "html"
|
|
190
|
-
? `${banner}\n\n${html.slice(0, 19_500)}`
|
|
191
|
-
: `${banner}\n\nRaw HTML (format='${fmt}' not applied):\n\n${html.slice(0, 19_000)}`;
|
|
192
|
-
|
|
193
|
-
return {
|
|
194
|
-
content: [{ type: "text", text: body.slice(0, 20_000) }],
|
|
195
|
-
details: { source: "curl-fallback", chars: html.length },
|
|
196
|
-
};
|
|
197
|
-
} catch (curlErr: unknown) {
|
|
198
|
-
const curlMsg = curlErr instanceof Error ? curlErr.message : String(curlErr);
|
|
199
|
-
return {
|
|
200
|
-
content: [
|
|
201
|
-
{
|
|
202
|
-
type: "text",
|
|
203
|
-
text: `Fetch failed (both API and curl).\nAPI: ${apiMsg}\nCurl: ${curlMsg}`,
|
|
204
|
-
},
|
|
205
|
-
],
|
|
206
|
-
details: { source: "failed" },
|
|
207
|
-
isError: true,
|
|
208
|
-
};
|
|
209
|
-
}
|
|
319
|
+
return executeFetch(params as FetchParams, signal, onUpdate);
|
|
210
320
|
},
|
|
211
321
|
});
|
|
212
322
|
}
|
package/src/format.test.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
|
-
import { formatFetchResult } from "./fetch.ts";
|
|
3
|
-
import {
|
|
2
|
+
import registerFetch, { executeFetch, formatFetchResult } from "./fetch.ts";
|
|
3
|
+
import registerSearch, {
|
|
4
|
+
executeSearch,
|
|
5
|
+
formatSearchResults,
|
|
6
|
+
parseSearchResults,
|
|
7
|
+
} from "./search.ts";
|
|
4
8
|
|
|
5
9
|
describe("formatSearchResults", () => {
|
|
6
10
|
it("formats results as a numbered markdown list", () => {
|
|
@@ -35,7 +39,11 @@ describe("formatSearchResults", () => {
|
|
|
35
39
|
expect(out).not.toContain('"provider"');
|
|
36
40
|
});
|
|
37
41
|
|
|
38
|
-
it("handles empty results", () => {
|
|
42
|
+
it("handles empty results and reports the authoritative count", () => {
|
|
43
|
+
expect(parseSearchResults(JSON.stringify({ results: [] }))).toEqual({
|
|
44
|
+
text: "No results.",
|
|
45
|
+
count: 0,
|
|
46
|
+
});
|
|
39
47
|
expect(formatSearchResults(JSON.stringify({ results: [] }))).toBe("No results.");
|
|
40
48
|
});
|
|
41
49
|
|
|
@@ -58,6 +66,311 @@ describe("formatSearchResults", () => {
|
|
|
58
66
|
});
|
|
59
67
|
});
|
|
60
68
|
|
|
69
|
+
describe("fetch execution metadata", () => {
|
|
70
|
+
it("attaches API success metadata without changing returned content", async () => {
|
|
71
|
+
const raw = JSON.stringify({
|
|
72
|
+
url: "https://example.com",
|
|
73
|
+
content: { text: "x".repeat(120) },
|
|
74
|
+
});
|
|
75
|
+
const originalContent = [
|
|
76
|
+
{
|
|
77
|
+
type: "text" as const,
|
|
78
|
+
text: `URL: https://example.com\n\n${"x".repeat(120)}`,
|
|
79
|
+
},
|
|
80
|
+
];
|
|
81
|
+
const result = await executeFetch(
|
|
82
|
+
{ url: "https://example.com", format: "markdown", max_characters: 1000 },
|
|
83
|
+
undefined,
|
|
84
|
+
undefined,
|
|
85
|
+
{
|
|
86
|
+
apiPost: async () => raw,
|
|
87
|
+
curl: async () => {
|
|
88
|
+
throw new Error("curl should not run");
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
expect(result.details).toMatchObject({
|
|
94
|
+
_type: "fetchResult",
|
|
95
|
+
outcome: "success",
|
|
96
|
+
url: "https://example.com",
|
|
97
|
+
format: "markdown",
|
|
98
|
+
source: "api",
|
|
99
|
+
chars: 146,
|
|
100
|
+
});
|
|
101
|
+
expect(result.content).toEqual(originalContent);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("reports curl fallback metadata while preserving the fallback banner", async () => {
|
|
105
|
+
const result = await executeFetch(
|
|
106
|
+
{ url: "https://example.com", format: "html", max_characters: 1000 },
|
|
107
|
+
undefined,
|
|
108
|
+
undefined,
|
|
109
|
+
{
|
|
110
|
+
apiPost: async () => {
|
|
111
|
+
throw new Error("API unavailable");
|
|
112
|
+
},
|
|
113
|
+
curl: async () => "<p>fallback</p>",
|
|
114
|
+
},
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
expect(result.details).toMatchObject({
|
|
118
|
+
_type: "fetchResult",
|
|
119
|
+
outcome: "fallback",
|
|
120
|
+
source: "curl-fallback",
|
|
121
|
+
chars: 15,
|
|
122
|
+
});
|
|
123
|
+
expect(result.content[0]?.text).toContain("[FALLBACK — raw curl]");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("returns a structured failure when both sources fail", async () => {
|
|
127
|
+
const result = await executeFetch(
|
|
128
|
+
{ url: "https://example.com", format: "text" },
|
|
129
|
+
undefined,
|
|
130
|
+
undefined,
|
|
131
|
+
{
|
|
132
|
+
apiPost: async () => {
|
|
133
|
+
throw new Error("api boom");
|
|
134
|
+
},
|
|
135
|
+
curl: async () => {
|
|
136
|
+
throw new Error("curl boom");
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
expect(result.isError).toBe(true);
|
|
142
|
+
expect(result.details).toMatchObject({
|
|
143
|
+
_type: "fetchResult",
|
|
144
|
+
outcome: "error",
|
|
145
|
+
source: "failed",
|
|
146
|
+
});
|
|
147
|
+
expect(result.content[0]?.text).toContain("API: api boom");
|
|
148
|
+
expect(result.content[0]?.text).toContain("Curl: curl boom");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("does not attempt curl after caller cancellation", async () => {
|
|
152
|
+
const controller = new AbortController();
|
|
153
|
+
controller.abort();
|
|
154
|
+
let curlCalls = 0;
|
|
155
|
+
const result = await executeFetch(
|
|
156
|
+
{ url: "https://example.com", format: "markdown" },
|
|
157
|
+
controller.signal,
|
|
158
|
+
undefined,
|
|
159
|
+
{
|
|
160
|
+
apiPost: async () => {
|
|
161
|
+
throw new DOMException("aborted", "AbortError");
|
|
162
|
+
},
|
|
163
|
+
curl: async () => {
|
|
164
|
+
curlCalls++;
|
|
165
|
+
return "unexpected";
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
expect(curlCalls).toBe(0);
|
|
171
|
+
expect(result.details).toMatchObject({
|
|
172
|
+
_type: "fetchResult",
|
|
173
|
+
outcome: "cancelled",
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe("search execution metadata", () => {
|
|
179
|
+
it("records API result count and preserves formatted content", async () => {
|
|
180
|
+
const raw = JSON.stringify({
|
|
181
|
+
results: [{ title: "One", url: "https://example.com/one" }],
|
|
182
|
+
});
|
|
183
|
+
const originalContent = [{ type: "text" as const, text: "1. One\n https://example.com/one" }];
|
|
184
|
+
const result = await executeSearch(
|
|
185
|
+
{ query: "example", search_type: "web", max_results: 5 },
|
|
186
|
+
undefined,
|
|
187
|
+
undefined,
|
|
188
|
+
{
|
|
189
|
+
apiPost: async () => raw,
|
|
190
|
+
curl: async () => {
|
|
191
|
+
throw new Error("curl should not run");
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
expect(result.details).toMatchObject({
|
|
197
|
+
_type: "searchResult",
|
|
198
|
+
outcome: "success",
|
|
199
|
+
query: "example",
|
|
200
|
+
searchType: "web",
|
|
201
|
+
source: "api",
|
|
202
|
+
resultCount: 1,
|
|
203
|
+
});
|
|
204
|
+
expect(result.content).toEqual(originalContent);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("records zero API results", async () => {
|
|
208
|
+
const result = await executeSearch(
|
|
209
|
+
{ query: "nothing", search_type: "news" },
|
|
210
|
+
undefined,
|
|
211
|
+
undefined,
|
|
212
|
+
{
|
|
213
|
+
apiPost: async () => '{"results":[]}',
|
|
214
|
+
curl: async () => "",
|
|
215
|
+
},
|
|
216
|
+
);
|
|
217
|
+
expect(result.content).toEqual([{ type: "text", text: "No results." }]);
|
|
218
|
+
expect(result.details.resultCount).toBe(0);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("records curl fallback count and banner", async () => {
|
|
222
|
+
const result = await executeSearch(
|
|
223
|
+
{ query: "fallback", search_type: "web" },
|
|
224
|
+
undefined,
|
|
225
|
+
undefined,
|
|
226
|
+
{
|
|
227
|
+
apiPost: async () => {
|
|
228
|
+
throw new Error("api boom");
|
|
229
|
+
},
|
|
230
|
+
curl: async () => '{"results":[{"title":"A"},{"title":"B"}]}',
|
|
231
|
+
},
|
|
232
|
+
);
|
|
233
|
+
expect(result.details).toMatchObject({
|
|
234
|
+
outcome: "fallback",
|
|
235
|
+
source: "curl-fallback",
|
|
236
|
+
resultCount: 2,
|
|
237
|
+
});
|
|
238
|
+
expect(result.content[0]?.text).toContain("[FALLBACK — curl]");
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("reports both-source failure and skips fallback after abort", async () => {
|
|
242
|
+
const failed = await executeSearch(
|
|
243
|
+
{ query: "failure", search_type: "web" },
|
|
244
|
+
undefined,
|
|
245
|
+
undefined,
|
|
246
|
+
{
|
|
247
|
+
apiPost: async () => {
|
|
248
|
+
throw new Error("api boom");
|
|
249
|
+
},
|
|
250
|
+
curl: async () => {
|
|
251
|
+
throw new Error("curl boom");
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
);
|
|
255
|
+
expect(failed.isError).toBe(true);
|
|
256
|
+
expect(failed.details).toMatchObject({ outcome: "error", source: "failed" });
|
|
257
|
+
|
|
258
|
+
const controller = new AbortController();
|
|
259
|
+
controller.abort();
|
|
260
|
+
let curlCalls = 0;
|
|
261
|
+
const cancelled = await executeSearch(
|
|
262
|
+
{ query: "cancelled", search_type: "news" },
|
|
263
|
+
controller.signal,
|
|
264
|
+
undefined,
|
|
265
|
+
{
|
|
266
|
+
apiPost: async () => {
|
|
267
|
+
throw new DOMException("aborted", "AbortError");
|
|
268
|
+
},
|
|
269
|
+
curl: async () => {
|
|
270
|
+
curlCalls++;
|
|
271
|
+
return "unexpected";
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
);
|
|
275
|
+
expect(curlCalls).toBe(0);
|
|
276
|
+
expect(cancelled.details.outcome).toBe("cancelled");
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
const renderTheme = {
|
|
281
|
+
fg: (token: string, text: string) => `[${token}]${text}`,
|
|
282
|
+
bold: (text: string) => `*${text}*`,
|
|
283
|
+
} as never;
|
|
284
|
+
|
|
285
|
+
function renderRegisteredResult(
|
|
286
|
+
register: (pi: never) => void,
|
|
287
|
+
result: Record<string, unknown>,
|
|
288
|
+
expanded = false,
|
|
289
|
+
isError = false,
|
|
290
|
+
): string {
|
|
291
|
+
let tool: { renderResult?: (...args: never[]) => unknown } | undefined;
|
|
292
|
+
register({
|
|
293
|
+
registerTool(value: { renderResult?: (...args: never[]) => unknown }) {
|
|
294
|
+
tool = value;
|
|
295
|
+
},
|
|
296
|
+
} as never);
|
|
297
|
+
let text = "";
|
|
298
|
+
const component = {
|
|
299
|
+
setText(value: string) {
|
|
300
|
+
text = value;
|
|
301
|
+
},
|
|
302
|
+
render: () => [],
|
|
303
|
+
invalidate: () => {},
|
|
304
|
+
};
|
|
305
|
+
tool?.renderResult?.(result as never, { expanded, isPartial: false } as never, renderTheme, {
|
|
306
|
+
lastComponent: component,
|
|
307
|
+
isError,
|
|
308
|
+
state: { collapsed: true },
|
|
309
|
+
expanded,
|
|
310
|
+
invalidate: () => {},
|
|
311
|
+
} as never);
|
|
312
|
+
return text;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
describe("fetch and search compact renderers", () => {
|
|
316
|
+
it("renders fallback and failure rows from metadata", () => {
|
|
317
|
+
const fetchRow = renderRegisteredResult(registerFetch, {
|
|
318
|
+
content: [{ type: "text", text: "fallback body" }],
|
|
319
|
+
details: {
|
|
320
|
+
_type: "fetchResult",
|
|
321
|
+
outcome: "fallback",
|
|
322
|
+
url: "https://example.com",
|
|
323
|
+
format: "markdown",
|
|
324
|
+
source: "curl-fallback",
|
|
325
|
+
chars: 120,
|
|
326
|
+
},
|
|
327
|
+
});
|
|
328
|
+
expect(fetchRow).toContain("⚡");
|
|
329
|
+
expect(fetchRow).toContain("120 chars · curl fallback");
|
|
330
|
+
|
|
331
|
+
const searchRow = renderRegisteredResult(
|
|
332
|
+
registerSearch,
|
|
333
|
+
{
|
|
334
|
+
content: [{ type: "text", text: "exact API and curl diagnostics" }],
|
|
335
|
+
details: {
|
|
336
|
+
_type: "searchResult",
|
|
337
|
+
outcome: "error",
|
|
338
|
+
query: "broken query",
|
|
339
|
+
searchType: "web",
|
|
340
|
+
source: "failed",
|
|
341
|
+
},
|
|
342
|
+
},
|
|
343
|
+
false,
|
|
344
|
+
true,
|
|
345
|
+
);
|
|
346
|
+
expect(searchRow).toContain("✗");
|
|
347
|
+
expect(searchRow).toContain("“broken query”");
|
|
348
|
+
expect(searchRow).toContain("failed");
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it("restores exact failure diagnostics on expansion", () => {
|
|
352
|
+
const diagnostic = "Search failed.\nAPI: unavailable\nCurl: exit 7";
|
|
353
|
+
const output = renderRegisteredResult(
|
|
354
|
+
registerSearch,
|
|
355
|
+
{
|
|
356
|
+
content: [{ type: "text", text: diagnostic }],
|
|
357
|
+
details: {
|
|
358
|
+
_type: "searchResult",
|
|
359
|
+
outcome: "error",
|
|
360
|
+
query: "broken query",
|
|
361
|
+
searchType: "web",
|
|
362
|
+
source: "failed",
|
|
363
|
+
},
|
|
364
|
+
},
|
|
365
|
+
true,
|
|
366
|
+
true,
|
|
367
|
+
);
|
|
368
|
+
expect(output).toContain("Search failed.");
|
|
369
|
+
expect(output).toContain("API: unavailable");
|
|
370
|
+
expect(output).toContain("Curl: exit 7");
|
|
371
|
+
});
|
|
372
|
+
});
|
|
373
|
+
|
|
61
374
|
describe("formatFetchResult", () => {
|
|
62
375
|
it("extracts content.text with title/url header", () => {
|
|
63
376
|
const raw = JSON.stringify({
|