@xynogen/pix-9router 0.4.3 → 0.4.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/package.json +3 -2
- package/src/data.test.ts +0 -122
- package/src/format.test.ts +0 -441
- package/src/provider.test.ts +0 -213
- package/src/render.test.ts +0 -218
- package/src/tool-schema.test.ts +0 -60
- package/src/transcribe.test.ts +0 -517
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xynogen/pix-9router",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.5",
|
|
4
4
|
"description": "Pi extension — 9Router provider + fetch/search tools via router API",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
11
|
"src",
|
|
12
|
+
"!src/**/*.test.*",
|
|
12
13
|
"README.md",
|
|
13
14
|
"LICENSE"
|
|
14
15
|
],
|
|
@@ -43,7 +44,7 @@
|
|
|
43
44
|
"@earendil-works/pi-tui": "*"
|
|
44
45
|
},
|
|
45
46
|
"dependencies": {
|
|
46
|
-
"@xynogen/pix-pretty": "^1.
|
|
47
|
+
"@xynogen/pix-pretty": "^1.25.0",
|
|
47
48
|
"typebox": "^1.1.38",
|
|
48
49
|
"@xynogen/pix-runtime": "^0.8.0"
|
|
49
50
|
}
|
package/src/data.test.ts
DELETED
|
@@ -1,122 +0,0 @@
|
|
|
1
|
-
import { afterEach, describe, expect, it } from "bun:test";
|
|
2
|
-
import { buildModelsDevIndex, lookupInIndex, type ModelGrepModel, routerBaseUrl } from "./data.ts";
|
|
3
|
-
|
|
4
|
-
// modelgrep-shaped fixture builder (buildModelsDevIndex now takes ModelGrepModel[]).
|
|
5
|
-
function mg(
|
|
6
|
-
id: string,
|
|
7
|
-
opts: { name?: string; reasoning?: boolean; input?: string[] } = {},
|
|
8
|
-
): ModelGrepModel {
|
|
9
|
-
return {
|
|
10
|
-
id,
|
|
11
|
-
name: opts.name ?? id,
|
|
12
|
-
capabilities: { reasoning: opts.reasoning },
|
|
13
|
-
modality: { input: opts.input },
|
|
14
|
-
};
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
// ── routerBaseUrl ────────────────────────────────────────────────────────────
|
|
18
|
-
|
|
19
|
-
describe("routerBaseUrl", () => {
|
|
20
|
-
const ORIGINAL = process.env.ROUTER_API_BASE;
|
|
21
|
-
|
|
22
|
-
afterEach(() => {
|
|
23
|
-
if (ORIGINAL === undefined) delete process.env.ROUTER_API_BASE;
|
|
24
|
-
else process.env.ROUTER_API_BASE = ORIGINAL;
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
it("returns default when env unset", () => {
|
|
28
|
-
delete process.env.ROUTER_API_BASE;
|
|
29
|
-
expect(routerBaseUrl()).toBe("https://9router.example.com/v1");
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
it("uses ROUTER_API_BASE when set", () => {
|
|
33
|
-
process.env.ROUTER_API_BASE = "https://my.router.dev/v1";
|
|
34
|
-
expect(routerBaseUrl()).toBe("https://my.router.dev/v1");
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it("strips trailing slash", () => {
|
|
38
|
-
process.env.ROUTER_API_BASE = "https://my.router.dev/v1/";
|
|
39
|
-
expect(routerBaseUrl()).toBe("https://my.router.dev/v1");
|
|
40
|
-
});
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
// ── buildModelsDevIndex ──────────────────────────────────────────────────────
|
|
44
|
-
|
|
45
|
-
describe("buildModelsDevIndex", () => {
|
|
46
|
-
const catalog: ModelGrepModel[] = [
|
|
47
|
-
mg("anthropic/claude-sonnet-4-5", { name: "Claude Sonnet 4.5" }),
|
|
48
|
-
mg("anthropic/claude-opus-4", { name: "Claude Opus 4", reasoning: true }),
|
|
49
|
-
mg("openai/gpt-4o", { name: "GPT-4o", input: ["text", "image"] }),
|
|
50
|
-
];
|
|
51
|
-
|
|
52
|
-
it("indexes all models by slug", () => {
|
|
53
|
-
const idx = buildModelsDevIndex(catalog);
|
|
54
|
-
expect(idx.has("claude-sonnet-4-5")).toBe(true);
|
|
55
|
-
expect(idx.has("claude-opus-4")).toBe(true);
|
|
56
|
-
expect(idx.has("gpt-4o")).toBe(true);
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
it("indexes normalized slug (strip date suffix)", () => {
|
|
60
|
-
const idx = buildModelsDevIndex([
|
|
61
|
-
mg("anthropic/claude-sonnet-4-5-20250514", { name: "Claude Sonnet 4.5" }),
|
|
62
|
-
]);
|
|
63
|
-
expect(idx.has("claude-sonnet-4-5")).toBe(true);
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it("handles empty catalog gracefully", () => {
|
|
67
|
-
expect(buildModelsDevIndex([]).size).toBe(0);
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
it("preserves first-seen on slug collision", () => {
|
|
71
|
-
const idx = buildModelsDevIndex([
|
|
72
|
-
mg("a/gpt-4o", { name: "First" }),
|
|
73
|
-
mg("b/gpt-4o", { name: "Second" }),
|
|
74
|
-
]);
|
|
75
|
-
expect(idx.get("gpt-4o")?.name).toBe("First");
|
|
76
|
-
});
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
// ── lookupInIndex ────────────────────────────────────────────────────────────
|
|
80
|
-
|
|
81
|
-
describe("lookupInIndex", () => {
|
|
82
|
-
const index = buildModelsDevIndex([
|
|
83
|
-
mg("anthropic/claude-sonnet-4-5", { name: "Claude Sonnet 4.5" }),
|
|
84
|
-
mg("anthropic/claude-opus-4", { name: "Claude Opus 4" }),
|
|
85
|
-
mg("openai/gpt-4o", { name: "GPT-4o" }),
|
|
86
|
-
mg("openai/o3-mini", { name: "o3 mini" }),
|
|
87
|
-
]);
|
|
88
|
-
|
|
89
|
-
it("finds exact match", () => {
|
|
90
|
-
expect(lookupInIndex("claude-sonnet-4-5", index)?.name).toBe("Claude Sonnet 4.5");
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
it("finds by stripping provider prefix (router style: provider/model)", () => {
|
|
94
|
-
expect(lookupInIndex("anthropic/claude-opus-4", index)?.name).toBe("Claude Opus 4");
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
it("finds with deep prefix e.g. cc/claude-opus-4", () => {
|
|
98
|
-
expect(lookupInIndex("cc/claude-opus-4", index)?.name).toBe("Claude Opus 4");
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
it("finds with date suffix stripped", () => {
|
|
102
|
-
expect(lookupInIndex("claude-sonnet-4-5-20250514", index)?.name).toBe("Claude Sonnet 4.5");
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
it("finds with provider prefix + date suffix", () => {
|
|
106
|
-
expect(lookupInIndex("anthropic/claude-sonnet-4-5-20250514", index)?.name).toBe(
|
|
107
|
-
"Claude Sonnet 4.5",
|
|
108
|
-
);
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
it("returns undefined for unknown model", () => {
|
|
112
|
-
expect(lookupInIndex("nonexistent-model-xyz", index)).toBeUndefined();
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
it("finds gpt-4o exact", () => {
|
|
116
|
-
expect(lookupInIndex("gpt-4o", index)?.name).toBe("GPT-4o");
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
it("finds o3-mini exact", () => {
|
|
120
|
-
expect(lookupInIndex("o3-mini", index)?.name).toBe("o3 mini");
|
|
121
|
-
});
|
|
122
|
-
});
|
package/src/format.test.ts
DELETED
|
@@ -1,441 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "bun:test";
|
|
2
|
-
import { COLLAPSED_TOOL_GLYPH } from "@xynogen/pix-pretty/utils";
|
|
3
|
-
import registerFetch, { executeFetch, formatFetchResult } from "./fetch.ts";
|
|
4
|
-
import registerSearch, {
|
|
5
|
-
executeSearch,
|
|
6
|
-
formatSearchResults,
|
|
7
|
-
parseSearchResults,
|
|
8
|
-
} from "./search.ts";
|
|
9
|
-
|
|
10
|
-
describe("formatSearchResults", () => {
|
|
11
|
-
it("formats results as a numbered markdown list", () => {
|
|
12
|
-
const raw = JSON.stringify({
|
|
13
|
-
provider: "exa",
|
|
14
|
-
query: "test",
|
|
15
|
-
results: [
|
|
16
|
-
{
|
|
17
|
-
title: "First Result",
|
|
18
|
-
url: "https://example.com/a",
|
|
19
|
-
snippet: "Some snippet\n text here",
|
|
20
|
-
published_at: "2026-03-04T22:36:57.000Z",
|
|
21
|
-
metadata: { author: "alice" },
|
|
22
|
-
},
|
|
23
|
-
{
|
|
24
|
-
title: "",
|
|
25
|
-
url: "https://example.com/b",
|
|
26
|
-
snippet: null,
|
|
27
|
-
},
|
|
28
|
-
],
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
const out = formatSearchResults(raw);
|
|
32
|
-
expect(out).toContain("1. First Result");
|
|
33
|
-
expect(out).toContain(" https://example.com/a");
|
|
34
|
-
expect(out).toContain("(alice — 2026-03-04)");
|
|
35
|
-
expect(out).toContain("Some snippet text here");
|
|
36
|
-
// untitled result falls back to url as title
|
|
37
|
-
expect(out).toContain("2. https://example.com/b");
|
|
38
|
-
// no raw JSON noise
|
|
39
|
-
expect(out).not.toContain("{");
|
|
40
|
-
expect(out).not.toContain('"provider"');
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
it("handles empty results and reports the authoritative count", () => {
|
|
44
|
-
expect(parseSearchResults(JSON.stringify({ results: [] }))).toEqual({
|
|
45
|
-
text: "No results.",
|
|
46
|
-
count: 0,
|
|
47
|
-
});
|
|
48
|
-
expect(formatSearchResults(JSON.stringify({ results: [] }))).toBe("No results.");
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
it("falls back to pretty JSON for unexpected shapes", () => {
|
|
52
|
-
const raw = JSON.stringify({ answer: "42" });
|
|
53
|
-
expect(formatSearchResults(raw)).toContain('"answer"');
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
it("returns raw string when not JSON", () => {
|
|
57
|
-
expect(formatSearchResults("plain text error")).toBe("plain text error");
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
it("truncates long snippets to 300 chars", () => {
|
|
61
|
-
const raw = JSON.stringify({
|
|
62
|
-
results: [{ title: "T", url: "https://x.dev", snippet: "y".repeat(1000) }],
|
|
63
|
-
});
|
|
64
|
-
const out = formatSearchResults(raw);
|
|
65
|
-
const snippetLine = out.split("\n").find((l) => l.trimStart().startsWith("yyy"));
|
|
66
|
-
expect(snippetLine?.trim().length).toBe(300);
|
|
67
|
-
});
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
describe("fetch execution metadata", () => {
|
|
71
|
-
it("attaches API success metadata without changing returned content", async () => {
|
|
72
|
-
const raw = JSON.stringify({
|
|
73
|
-
url: "https://example.com",
|
|
74
|
-
content: { text: "x".repeat(120) },
|
|
75
|
-
});
|
|
76
|
-
const originalContent = [
|
|
77
|
-
{
|
|
78
|
-
type: "text" as const,
|
|
79
|
-
text: `URL: https://example.com\n\n${"x".repeat(120)}`,
|
|
80
|
-
},
|
|
81
|
-
];
|
|
82
|
-
const result = await executeFetch(
|
|
83
|
-
{ url: "https://example.com", format: "markdown", max_characters: 1000 },
|
|
84
|
-
undefined,
|
|
85
|
-
undefined,
|
|
86
|
-
{
|
|
87
|
-
apiPost: async () => raw,
|
|
88
|
-
curl: async () => {
|
|
89
|
-
throw new Error("curl should not run");
|
|
90
|
-
},
|
|
91
|
-
},
|
|
92
|
-
);
|
|
93
|
-
|
|
94
|
-
expect(result.details).toMatchObject({
|
|
95
|
-
_type: "fetchResult",
|
|
96
|
-
outcome: "success",
|
|
97
|
-
url: "https://example.com",
|
|
98
|
-
format: "markdown",
|
|
99
|
-
source: "api",
|
|
100
|
-
chars: 146,
|
|
101
|
-
});
|
|
102
|
-
expect(result.content).toEqual(originalContent);
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
it("defaults format to markdown when omitted (schema now optional)", async () => {
|
|
106
|
-
let sentFormat: unknown;
|
|
107
|
-
const raw = JSON.stringify({ url: "https://example.com", content: { text: "hello" } });
|
|
108
|
-
const result = await executeFetch(
|
|
109
|
-
// No `format` — valid now that the schema no longer requires it.
|
|
110
|
-
{ url: "https://example.com" },
|
|
111
|
-
undefined,
|
|
112
|
-
undefined,
|
|
113
|
-
{
|
|
114
|
-
apiPost: async (_path, body) => {
|
|
115
|
-
sentFormat = (body as { format?: unknown }).format;
|
|
116
|
-
return raw;
|
|
117
|
-
},
|
|
118
|
-
curl: async () => {
|
|
119
|
-
throw new Error("curl should not run");
|
|
120
|
-
},
|
|
121
|
-
},
|
|
122
|
-
);
|
|
123
|
-
|
|
124
|
-
// The default flows through to both the API request and the metadata.
|
|
125
|
-
expect(sentFormat).toBe("markdown");
|
|
126
|
-
expect(result.details).toMatchObject({ outcome: "success", format: "markdown" });
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
it("reports curl fallback metadata while preserving the fallback banner", async () => {
|
|
130
|
-
const result = await executeFetch(
|
|
131
|
-
{ url: "https://example.com", format: "html", max_characters: 1000 },
|
|
132
|
-
undefined,
|
|
133
|
-
undefined,
|
|
134
|
-
{
|
|
135
|
-
apiPost: async () => {
|
|
136
|
-
throw new Error("API unavailable");
|
|
137
|
-
},
|
|
138
|
-
curl: async () => "<p>fallback</p>",
|
|
139
|
-
},
|
|
140
|
-
);
|
|
141
|
-
|
|
142
|
-
expect(result.details).toMatchObject({
|
|
143
|
-
_type: "fetchResult",
|
|
144
|
-
outcome: "fallback",
|
|
145
|
-
source: "curl-fallback",
|
|
146
|
-
chars: 15,
|
|
147
|
-
});
|
|
148
|
-
expect(result.content[0]?.text).toContain("[FALLBACK — raw curl]");
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
it("returns a structured failure when both sources fail", async () => {
|
|
152
|
-
const result = await executeFetch(
|
|
153
|
-
{ url: "https://example.com", format: "text" },
|
|
154
|
-
undefined,
|
|
155
|
-
undefined,
|
|
156
|
-
{
|
|
157
|
-
apiPost: async () => {
|
|
158
|
-
throw new Error("api boom");
|
|
159
|
-
},
|
|
160
|
-
curl: async () => {
|
|
161
|
-
throw new Error("curl boom");
|
|
162
|
-
},
|
|
163
|
-
},
|
|
164
|
-
);
|
|
165
|
-
|
|
166
|
-
expect(result.isError).toBe(true);
|
|
167
|
-
expect(result.details).toMatchObject({
|
|
168
|
-
_type: "fetchResult",
|
|
169
|
-
outcome: "error",
|
|
170
|
-
source: "failed",
|
|
171
|
-
});
|
|
172
|
-
expect(result.content[0]?.text).toContain("API: api boom");
|
|
173
|
-
expect(result.content[0]?.text).toContain("Curl: curl boom");
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
it("does not attempt curl after caller cancellation", async () => {
|
|
177
|
-
const controller = new AbortController();
|
|
178
|
-
controller.abort();
|
|
179
|
-
let curlCalls = 0;
|
|
180
|
-
const result = await executeFetch(
|
|
181
|
-
{ url: "https://example.com", format: "markdown" },
|
|
182
|
-
controller.signal,
|
|
183
|
-
undefined,
|
|
184
|
-
{
|
|
185
|
-
apiPost: async () => {
|
|
186
|
-
throw new DOMException("aborted", "AbortError");
|
|
187
|
-
},
|
|
188
|
-
curl: async () => {
|
|
189
|
-
curlCalls++;
|
|
190
|
-
return "unexpected";
|
|
191
|
-
},
|
|
192
|
-
},
|
|
193
|
-
);
|
|
194
|
-
|
|
195
|
-
expect(curlCalls).toBe(0);
|
|
196
|
-
expect(result.details).toMatchObject({
|
|
197
|
-
_type: "fetchResult",
|
|
198
|
-
outcome: "cancelled",
|
|
199
|
-
});
|
|
200
|
-
});
|
|
201
|
-
});
|
|
202
|
-
|
|
203
|
-
describe("search execution metadata", () => {
|
|
204
|
-
it("records API result count and preserves formatted content", async () => {
|
|
205
|
-
const raw = JSON.stringify({
|
|
206
|
-
results: [{ title: "One", url: "https://example.com/one" }],
|
|
207
|
-
});
|
|
208
|
-
const originalContent = [{ type: "text" as const, text: "1. One\n https://example.com/one" }];
|
|
209
|
-
const result = await executeSearch(
|
|
210
|
-
{ query: "example", search_type: "web", max_results: 5 },
|
|
211
|
-
undefined,
|
|
212
|
-
undefined,
|
|
213
|
-
{
|
|
214
|
-
apiPost: async () => raw,
|
|
215
|
-
curl: async () => {
|
|
216
|
-
throw new Error("curl should not run");
|
|
217
|
-
},
|
|
218
|
-
},
|
|
219
|
-
);
|
|
220
|
-
|
|
221
|
-
expect(result.details).toMatchObject({
|
|
222
|
-
_type: "searchResult",
|
|
223
|
-
outcome: "success",
|
|
224
|
-
query: "example",
|
|
225
|
-
searchType: "web",
|
|
226
|
-
source: "api",
|
|
227
|
-
resultCount: 1,
|
|
228
|
-
});
|
|
229
|
-
expect(result.content).toEqual(originalContent);
|
|
230
|
-
});
|
|
231
|
-
|
|
232
|
-
it("records zero API results", async () => {
|
|
233
|
-
const result = await executeSearch(
|
|
234
|
-
{ query: "nothing", search_type: "news" },
|
|
235
|
-
undefined,
|
|
236
|
-
undefined,
|
|
237
|
-
{
|
|
238
|
-
apiPost: async () => '{"results":[]}',
|
|
239
|
-
curl: async () => "",
|
|
240
|
-
},
|
|
241
|
-
);
|
|
242
|
-
expect(result.content).toEqual([{ type: "text", text: "No results." }]);
|
|
243
|
-
expect(result.details.resultCount).toBe(0);
|
|
244
|
-
});
|
|
245
|
-
|
|
246
|
-
it("records curl fallback count and banner", async () => {
|
|
247
|
-
const result = await executeSearch(
|
|
248
|
-
{ query: "fallback", search_type: "web" },
|
|
249
|
-
undefined,
|
|
250
|
-
undefined,
|
|
251
|
-
{
|
|
252
|
-
apiPost: async () => {
|
|
253
|
-
throw new Error("api boom");
|
|
254
|
-
},
|
|
255
|
-
curl: async () => '{"results":[{"title":"A"},{"title":"B"}]}',
|
|
256
|
-
},
|
|
257
|
-
);
|
|
258
|
-
expect(result.details).toMatchObject({
|
|
259
|
-
outcome: "fallback",
|
|
260
|
-
source: "curl-fallback",
|
|
261
|
-
resultCount: 2,
|
|
262
|
-
});
|
|
263
|
-
expect(result.content[0]?.text).toContain("[FALLBACK — curl]");
|
|
264
|
-
});
|
|
265
|
-
|
|
266
|
-
it("reports both-source failure and skips fallback after abort", async () => {
|
|
267
|
-
const failed = await executeSearch(
|
|
268
|
-
{ query: "failure", search_type: "web" },
|
|
269
|
-
undefined,
|
|
270
|
-
undefined,
|
|
271
|
-
{
|
|
272
|
-
apiPost: async () => {
|
|
273
|
-
throw new Error("api boom");
|
|
274
|
-
},
|
|
275
|
-
curl: async () => {
|
|
276
|
-
throw new Error("curl boom");
|
|
277
|
-
},
|
|
278
|
-
},
|
|
279
|
-
);
|
|
280
|
-
expect(failed.isError).toBe(true);
|
|
281
|
-
expect(failed.details).toMatchObject({ outcome: "error", source: "failed" });
|
|
282
|
-
|
|
283
|
-
const controller = new AbortController();
|
|
284
|
-
controller.abort();
|
|
285
|
-
let curlCalls = 0;
|
|
286
|
-
const cancelled = await executeSearch(
|
|
287
|
-
{ query: "cancelled", search_type: "news" },
|
|
288
|
-
controller.signal,
|
|
289
|
-
undefined,
|
|
290
|
-
{
|
|
291
|
-
apiPost: async () => {
|
|
292
|
-
throw new DOMException("aborted", "AbortError");
|
|
293
|
-
},
|
|
294
|
-
curl: async () => {
|
|
295
|
-
curlCalls++;
|
|
296
|
-
return "unexpected";
|
|
297
|
-
},
|
|
298
|
-
},
|
|
299
|
-
);
|
|
300
|
-
expect(curlCalls).toBe(0);
|
|
301
|
-
expect(cancelled.details.outcome).toBe("cancelled");
|
|
302
|
-
});
|
|
303
|
-
});
|
|
304
|
-
|
|
305
|
-
const renderTheme = {
|
|
306
|
-
fg: (token: string, text: string) => `[${token}]${text}`,
|
|
307
|
-
bold: (text: string) => `*${text}*`,
|
|
308
|
-
} as never;
|
|
309
|
-
|
|
310
|
-
function captureRegisteredTool(register: (pi: never) => void): Record<string, unknown> {
|
|
311
|
-
let tool: Record<string, unknown> | undefined;
|
|
312
|
-
register({
|
|
313
|
-
registerTool(value: Record<string, unknown>) {
|
|
314
|
-
tool = value;
|
|
315
|
-
},
|
|
316
|
-
} as never);
|
|
317
|
-
if (!tool) throw new Error("tool was not registered");
|
|
318
|
-
return tool;
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
function renderRegisteredResult(
|
|
322
|
-
register: (pi: never) => void,
|
|
323
|
-
result: Record<string, unknown>,
|
|
324
|
-
expanded = false,
|
|
325
|
-
isError = false,
|
|
326
|
-
): string {
|
|
327
|
-
let tool: { renderResult?: (...args: never[]) => unknown } | undefined;
|
|
328
|
-
register({
|
|
329
|
-
registerTool(value: { renderResult?: (...args: never[]) => unknown }) {
|
|
330
|
-
tool = value;
|
|
331
|
-
},
|
|
332
|
-
} as never);
|
|
333
|
-
let text = "";
|
|
334
|
-
const component = {
|
|
335
|
-
setText(value: string) {
|
|
336
|
-
text = value;
|
|
337
|
-
},
|
|
338
|
-
render: () => [],
|
|
339
|
-
invalidate: () => {},
|
|
340
|
-
};
|
|
341
|
-
tool?.renderResult?.(result as never, { expanded, isPartial: false } as never, renderTheme, {
|
|
342
|
-
lastComponent: component,
|
|
343
|
-
isError,
|
|
344
|
-
state: { collapsed: true },
|
|
345
|
-
expanded,
|
|
346
|
-
invalidate: () => {},
|
|
347
|
-
} as never);
|
|
348
|
-
return text;
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
describe("fetch and search compact renderers", () => {
|
|
352
|
-
it("use the self-rendered shell so status marks have no leading box padding", () => {
|
|
353
|
-
expect(captureRegisteredTool(registerFetch).renderShell).toBe("self");
|
|
354
|
-
expect(captureRegisteredTool(registerSearch).renderShell).toBe("self");
|
|
355
|
-
});
|
|
356
|
-
|
|
357
|
-
it("renders fallback and failure rows from metadata", () => {
|
|
358
|
-
const fetchRow = renderRegisteredResult(registerFetch, {
|
|
359
|
-
content: [{ type: "text", text: "fallback body" }],
|
|
360
|
-
details: {
|
|
361
|
-
_type: "fetchResult",
|
|
362
|
-
outcome: "fallback",
|
|
363
|
-
url: "https://example.com",
|
|
364
|
-
format: "markdown",
|
|
365
|
-
source: "curl-fallback",
|
|
366
|
-
chars: 120,
|
|
367
|
-
},
|
|
368
|
-
});
|
|
369
|
-
expect(fetchRow).toContain(COLLAPSED_TOOL_GLYPH.warning);
|
|
370
|
-
expect(fetchRow).toContain("120 chars · curl fallback");
|
|
371
|
-
|
|
372
|
-
const searchRow = renderRegisteredResult(
|
|
373
|
-
registerSearch,
|
|
374
|
-
{
|
|
375
|
-
content: [{ type: "text", text: "exact API and curl diagnostics" }],
|
|
376
|
-
details: {
|
|
377
|
-
_type: "searchResult",
|
|
378
|
-
outcome: "error",
|
|
379
|
-
query: "broken query",
|
|
380
|
-
searchType: "web",
|
|
381
|
-
source: "failed",
|
|
382
|
-
},
|
|
383
|
-
},
|
|
384
|
-
false,
|
|
385
|
-
true,
|
|
386
|
-
);
|
|
387
|
-
expect(searchRow).toContain("✗");
|
|
388
|
-
expect(searchRow).toContain("“broken query”");
|
|
389
|
-
expect(searchRow).toContain("failed");
|
|
390
|
-
});
|
|
391
|
-
|
|
392
|
-
it("restores exact failure diagnostics on expansion", () => {
|
|
393
|
-
const diagnostic = "Search failed.\nAPI: unavailable\nCurl: exit 7";
|
|
394
|
-
const output = renderRegisteredResult(
|
|
395
|
-
registerSearch,
|
|
396
|
-
{
|
|
397
|
-
content: [{ type: "text", text: diagnostic }],
|
|
398
|
-
details: {
|
|
399
|
-
_type: "searchResult",
|
|
400
|
-
outcome: "error",
|
|
401
|
-
query: "broken query",
|
|
402
|
-
searchType: "web",
|
|
403
|
-
source: "failed",
|
|
404
|
-
},
|
|
405
|
-
},
|
|
406
|
-
true,
|
|
407
|
-
true,
|
|
408
|
-
);
|
|
409
|
-
expect(output).toContain("Search failed.");
|
|
410
|
-
expect(output).toContain("API: unavailable");
|
|
411
|
-
expect(output).toContain("Curl: exit 7");
|
|
412
|
-
});
|
|
413
|
-
});
|
|
414
|
-
|
|
415
|
-
describe("formatFetchResult", () => {
|
|
416
|
-
it("extracts content.text with title/url header", () => {
|
|
417
|
-
const raw = JSON.stringify({
|
|
418
|
-
provider: "exa",
|
|
419
|
-
url: "https://example.com",
|
|
420
|
-
title: "Example Page",
|
|
421
|
-
content: { format: "markdown", text: "# Hello\n\nBody text." },
|
|
422
|
-
});
|
|
423
|
-
|
|
424
|
-
const out = formatFetchResult(raw);
|
|
425
|
-
expect(out).toBe("# Example Page\nURL: https://example.com\n\n# Hello\n\nBody text.");
|
|
426
|
-
});
|
|
427
|
-
|
|
428
|
-
it("supports content as a plain string", () => {
|
|
429
|
-
const raw = JSON.stringify({ content: "just text" });
|
|
430
|
-
expect(formatFetchResult(raw)).toBe("just text");
|
|
431
|
-
});
|
|
432
|
-
|
|
433
|
-
it("returns raw when content.text is missing", () => {
|
|
434
|
-
const raw = JSON.stringify({ status: "error" });
|
|
435
|
-
expect(formatFetchResult(raw)).toBe(raw);
|
|
436
|
-
});
|
|
437
|
-
|
|
438
|
-
it("returns raw when not JSON", () => {
|
|
439
|
-
expect(formatFetchResult("<html>raw</html>")).toBe("<html>raw</html>");
|
|
440
|
-
});
|
|
441
|
-
});
|