skyloom 1.15.4 → 1.16.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.
Files changed (47) hide show
  1. package/dist/cli/command_args.d.ts +74 -0
  2. package/dist/cli/command_args.d.ts.map +1 -0
  3. package/dist/cli/command_args.js +129 -0
  4. package/dist/cli/command_args.js.map +1 -0
  5. package/dist/cli/loom.d.ts +20 -0
  6. package/dist/cli/loom.d.ts.map +1 -1
  7. package/dist/cli/loom.js +202 -24
  8. package/dist/cli/loom.js.map +1 -1
  9. package/dist/cli/loom_chat.d.ts.map +1 -1
  10. package/dist/cli/loom_chat.js +39 -0
  11. package/dist/cli/loom_chat.js.map +1 -1
  12. package/dist/core/agent.js +2 -2
  13. package/dist/core/agent.js.map +1 -1
  14. package/dist/core/security.d.ts.map +1 -1
  15. package/dist/core/security.js +1 -0
  16. package/dist/core/security.js.map +1 -1
  17. package/dist/core/tool_router.d.ts.map +1 -1
  18. package/dist/core/tool_router.js +11 -3
  19. package/dist/core/tool_router.js.map +1 -1
  20. package/dist/tools/builtin.d.ts.map +1 -1
  21. package/dist/tools/builtin.js +38 -192
  22. package/dist/tools/builtin.js.map +1 -1
  23. package/dist/tools/websearch.d.ts +92 -0
  24. package/dist/tools/websearch.d.ts.map +1 -0
  25. package/dist/tools/websearch.js +343 -0
  26. package/dist/tools/websearch.js.map +1 -0
  27. package/dist/web/server.js +2 -9
  28. package/dist/web/server.js.map +1 -1
  29. package/dist/web/ui.d.ts.map +1 -1
  30. package/dist/web/ui.js +3 -2
  31. package/dist/web/ui.js.map +1 -1
  32. package/package.json +1 -1
  33. package/src/cli/command_args.ts +159 -0
  34. package/src/cli/loom.ts +155 -17
  35. package/src/cli/loom_chat.ts +33 -0
  36. package/src/core/agent.ts +2 -2
  37. package/src/core/security.ts +1 -0
  38. package/src/core/tool_router.ts +11 -3
  39. package/src/tools/builtin.ts +38 -190
  40. package/src/tools/websearch.ts +368 -0
  41. package/src/web/server.ts +2 -10
  42. package/src/web/ui.ts +3 -2
  43. package/tests/command_args.test.ts +115 -0
  44. package/tests/loom.test.ts +74 -0
  45. package/tests/tool_router.test.ts +15 -0
  46. package/tests/web.test.ts +7 -5
  47. package/tests/websearch.test.ts +190 -0
package/src/web/ui.ts CHANGED
@@ -559,8 +559,9 @@ export function renderInkWashUI(): string {
559
559
  <meta name="viewport" content="width=device-width,initial-scale=1.0">
560
560
  <meta name="color-scheme" content="light dark">
561
561
  <title>水墨气象台 · Skyloom</title>
562
- <link rel="icon" type="image/svg+xml" href="/favicon.svg">
563
- <link rel="shortcut icon" href="/favicon.ico">
562
+ <link rel="icon" type="image/svg+xml" sizes="any" href="/favicon.svg?v=1.15.5">
563
+ <link rel="shortcut icon" type="image/svg+xml" href="/favicon.ico?v=1.15.5">
564
+ <link rel="apple-touch-icon" href="/favicon.svg?v=1.15.5">
564
565
  <meta name="theme-color" content="#f7f3e9">
565
566
  <link rel="preconnect" href="https://fonts.googleapis.com">
566
567
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
@@ -0,0 +1,115 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ hasWizard, nextWizardStep, buildCommandLine, filterChoices,
4
+ type WizardContext, type ArgChoice,
5
+ } from "../src/cli/command_args";
6
+
7
+ const CTX: WizardContext = {
8
+ providers: [
9
+ { id: "openai", label: "OpenAI", configured: true, envVar: "OPENAI_API_KEY" },
10
+ { id: "deepseek", label: "DeepSeek", configured: false, envVar: "DEEPSEEK_API_KEY" },
11
+ { id: "ollama", label: "Ollama", configured: true },
12
+ ],
13
+ models: [
14
+ { id: "gpt-4o", provider: "openai", label: "gpt-4o", hint: "$2.5/$10" },
15
+ { id: "deepseek-chat", provider: "deepseek", label: "deepseek-chat", hint: "$0.3/$1.1" },
16
+ ],
17
+ sessions: [
18
+ { id: "abcdef123456", label: "重构搜索模块" },
19
+ { id: "ff00aa221133", label: "写测试" },
20
+ ],
21
+ };
22
+
23
+ describe("hasWizard", () => {
24
+ it("recognizes wizard commands with or without slash", () => {
25
+ for (const c of ["model", "/model", "apikey", "/apikey", "connect", "resume"]) {
26
+ expect(hasWizard(c), c).toBe(true);
27
+ }
28
+ });
29
+ it("rejects non-wizard commands", () => {
30
+ for (const c of ["help", "/status", "fog", "/clear", "verify"]) {
31
+ expect(hasWizard(c), c).toBe(false);
32
+ }
33
+ });
34
+ });
35
+
36
+ describe("nextWizardStep · /apikey (the key-config flow)", () => {
37
+ it("step 0 lists providers with configured badges", () => {
38
+ const step = nextWizardStep("/apikey", [], CTX)!;
39
+ expect(step.kind).toBe("choice");
40
+ expect(step.choices.map((c) => c.value)).toEqual(["openai", "deepseek", "ollama"]);
41
+ expect(step.choices[0].hint).toContain("已配置"); // openai configured
42
+ expect(step.choices[1].hint).toContain("DEEPSEEK_API_KEY"); // deepseek not configured
43
+ expect(step.allowFreeform).toBe(true);
44
+ });
45
+ it("step 1 prompts for the key, masked", () => {
46
+ const step = nextWizardStep("/apikey", ["deepseek"], CTX)!;
47
+ expect(step.kind).toBe("freeform");
48
+ expect(step.secret).toBe(true);
49
+ expect(step.title).toContain("deepseek");
50
+ });
51
+ it("is complete after provider + key", () => {
52
+ expect(nextWizardStep("/apikey", ["deepseek", "sk-xxx"], CTX)).toBeNull();
53
+ });
54
+ it("builds the correct command line", () => {
55
+ expect(buildCommandLine("/apikey", ["deepseek", "sk-xxx"])).toBe("/apikey set deepseek sk-xxx");
56
+ });
57
+ });
58
+
59
+ describe("nextWizardStep · /model", () => {
60
+ it("offers reset + every model, completes after one pick", () => {
61
+ const step = nextWizardStep("/model", [], CTX)!;
62
+ expect(step.choices[0].value).toBe("reset");
63
+ expect(step.choices.map((c) => c.value)).toContain("gpt-4o");
64
+ expect(step.choices.find((c) => c.value === "gpt-4o")!.group).toBe("openai");
65
+ expect(nextWizardStep("/model", ["gpt-4o"], CTX)).toBeNull();
66
+ });
67
+ it("builds /model <id> and /model reset", () => {
68
+ expect(buildCommandLine("/model", ["gpt-4o"])).toBe("/model gpt-4o");
69
+ expect(buildCommandLine("/model", ["reset"])).toBe("/model reset");
70
+ });
71
+ });
72
+
73
+ describe("nextWizardStep · /connect and /resume", () => {
74
+ it("connect picks a provider", () => {
75
+ const step = nextWizardStep("/connect", [], CTX)!;
76
+ expect(step.choices.map((c) => c.value)).toEqual(["openai", "deepseek", "ollama"]);
77
+ expect(nextWizardStep("/connect", ["openai"], CTX)).toBeNull();
78
+ expect(buildCommandLine("/connect", ["openai"])).toBe("/connect openai");
79
+ });
80
+ it("resume lists sessions by index, builds /resume <n>", () => {
81
+ const step = nextWizardStep("/resume", [], CTX)!;
82
+ expect(step.choices.map((c) => c.value)).toEqual(["1", "2"]);
83
+ expect(step.choices[0].label).toContain("重构搜索模块");
84
+ expect(buildCommandLine("/resume", ["1"])).toBe("/resume 1");
85
+ });
86
+ it("resume with no sessions still returns a (empty) step, not a crash", () => {
87
+ const step = nextWizardStep("/resume", [], { ...CTX, sessions: [] })!;
88
+ expect(step.choices).toHaveLength(0);
89
+ expect(step.allowFreeform).toBe(true);
90
+ });
91
+ });
92
+
93
+ describe("filterChoices", () => {
94
+ const choices: ArgChoice[] = [
95
+ { value: "gpt-4o", label: "gpt-4o", group: "openai" },
96
+ { value: "deepseek-chat", label: "deepseek-chat", group: "deepseek" },
97
+ { value: "gpt-4o-mini", label: "gpt-4o-mini", group: "openai" },
98
+ ];
99
+ it("returns all on empty query", () => {
100
+ expect(filterChoices(choices, "")).toHaveLength(3);
101
+ });
102
+ it("matches on value substring", () => {
103
+ expect(filterChoices(choices, "deepseek").map((c) => c.value)).toEqual(["deepseek-chat"]);
104
+ });
105
+ it("matches on group", () => {
106
+ expect(filterChoices(choices, "openai").map((c) => c.value).sort()).toEqual(["gpt-4o", "gpt-4o-mini"]);
107
+ });
108
+ it("ranks exact/prefix matches before substring matches", () => {
109
+ const ranked = filterChoices(choices, "gpt-4o");
110
+ expect(ranked[0].value).toBe("gpt-4o"); // exact first
111
+ });
112
+ it("returns empty when nothing matches", () => {
113
+ expect(filterChoices(choices, "zzz")).toHaveLength(0);
114
+ });
115
+ });
@@ -247,6 +247,80 @@ describe("palette ↑↓ navigation + Enter execution", () => {
247
247
  });
248
248
  });
249
249
 
250
+ describe("argument wizard (cascading ↑↓ selection)", () => {
251
+ function key(ui: any, name: string, opts: Record<string, any> = {}) { ui.onKey(opts.str ?? "", { name, ...opts }); }
252
+ function type(ui: any, text: string) { for (const ch of text) ui.onKey(ch, { name: ch }); }
253
+
254
+ // A two-level /apikey wizard: pick a provider, then paste a key.
255
+ function wireApikeyWizard(ui: any) {
256
+ ui.wizardStep = (command: string, prior: string[]) => {
257
+ if (!/apikey/.test(command)) return null;
258
+ if (prior.length === 0) return {
259
+ kind: "choice", title: "选择 Provider", allowFreeform: true,
260
+ choices: [
261
+ { value: "deepseek", label: "DeepSeek", hint: "未配置" },
262
+ { value: "openai", label: "OpenAI", hint: "✓ 已配置" },
263
+ ],
264
+ };
265
+ if (prior.length === 1) return { kind: "freeform", title: `粘贴 ${prior[0]} key`, choices: [], allowFreeform: true, secret: true };
266
+ return null;
267
+ };
268
+ }
269
+
270
+ it("selecting a wizard command opens the wizard and cascades to submit", async () => {
271
+ const ui = makeUI() as any;
272
+ wireApikeyWizard(ui);
273
+ const p = ui.readInput();
274
+ type(ui, "/apikey");
275
+ key(ui, "return"); // palette → opens the wizard (input cleared, not submitted)
276
+ expect(ui.inputGlyphs.length).toBe(0);
277
+
278
+ key(ui, "down"); // deepseek → openai
279
+ key(ui, "return"); // pick provider → advance to the key step
280
+ type(ui, "sk-secret");
281
+ key(ui, "return"); // submit
282
+
283
+ expect(await p).toBe("/apikey set openai sk-secret");
284
+ });
285
+
286
+ it("typing filters the choice list; Enter picks the filtered match", async () => {
287
+ const ui = makeUI() as any;
288
+ wireApikeyWizard(ui);
289
+ const p = ui.readInput();
290
+ type(ui, "/apikey");
291
+ key(ui, "return");
292
+ type(ui, "deep"); // filters to deepseek
293
+ key(ui, "return"); // pick it
294
+ type(ui, "k1");
295
+ key(ui, "return");
296
+ expect(await p).toBe("/apikey set deepseek k1");
297
+ });
298
+
299
+ it("backspace at an empty filter steps back a level; Esc cancels", async () => {
300
+ const ui = makeUI() as any;
301
+ wireApikeyWizard(ui);
302
+ ui.readInput();
303
+ type(ui, "/apikey");
304
+ key(ui, "return"); // wizard open at provider step
305
+ key(ui, "return"); // pick deepseek → key step
306
+ key(ui, "backspace"); // empty typed → back to provider step
307
+ // still in the wizard, not submitted; Esc closes it entirely
308
+ key(ui, "escape");
309
+ expect(ui.inputGlyphs.length).toBe(0);
310
+ // a frame still renders at full width after cancelling
311
+ for (const row of ui.paint()) expect(visualWidth(row)).toBe(80);
312
+ });
313
+
314
+ it("a non-wizard command is unaffected (submits directly)", async () => {
315
+ const ui = makeUI() as any;
316
+ wireApikeyWizard(ui); // only /apikey has a wizard
317
+ const p = ui.readInput();
318
+ type(ui, "/status");
319
+ key(ui, "return");
320
+ expect(await p).toBe("/status");
321
+ });
322
+ });
323
+
250
324
  describe("mouse wheel scrolling", () => {
251
325
  // Replay an SGR mouse sequence the way Node's keypress parser fragments it:
252
326
  // ESC[< as one event, then every remaining char separately.
@@ -53,6 +53,21 @@ describe('selectRelevantTools', () => {
53
53
  expect(selected).toContain('read_file');
54
54
  });
55
55
 
56
+ it('surfaces web_search for current-events queries among many tools', () => {
57
+ // Regression: "今日热点新闻" used to score 0 for web_search, so with a large
58
+ // tool catalog it never made the shortlist and the LLM couldn't use it.
59
+ const r = makeRegistry([
60
+ ['web_search', 'search the live web'],
61
+ ['read_url', 'read a web page as text'],
62
+ ...Array.from({ length: 30 }, (_, i): [string, string] => [`tool_${i}`, `unrelated capability ${i}`]),
63
+ ]);
64
+ const names = r.listNames();
65
+ for (const q of ['今日热点新闻', '最新的事件', "today's latest news", '查一下现在的天气']) {
66
+ const selected = selectRelevantTools(r, names, q, { topK: 8 });
67
+ expect(selected, `query: ${q}`).toContain('web_search');
68
+ }
69
+ });
70
+
56
71
  it('mustInclude always present', () => {
57
72
  const r = makeRegistry(Array.from({ length: 20 }, (_, i) => [`random_${i}`, `unrelated tool ${i}`]));
58
73
  r.register({
package/tests/web.test.ts CHANGED
@@ -138,16 +138,18 @@ describe("web · server", () => {
138
138
  const body = await page.text();
139
139
  expect(body).toContain("水墨气象台");
140
140
  expect(body).toContain("clientMain()");
141
- expect(body).toContain('href="/favicon.svg"');
141
+ expect(body).toContain('href="/favicon.svg?v=');
142
+ expect(body).toContain('href="/favicon.ico?v=');
142
143
 
143
- const icon = await fetch(`http://127.0.0.1:${port}/favicon.svg`);
144
+ const icon = await fetch(`http://127.0.0.1:${port}/favicon.svg?v=test`);
144
145
  expect(icon.status).toBe(200);
145
146
  expect(icon.headers.get("content-type")).toContain("image/svg+xml");
147
+ expect(icon.headers.get("cache-control")).toContain("no-cache");
146
148
  expect(await icon.text()).toContain("<svg");
147
149
 
148
- const legacyIcon = await fetch(`http://127.0.0.1:${port}/favicon.ico`, { redirect: "manual" });
149
- expect(legacyIcon.status).toBe(302);
150
- expect(legacyIcon.headers.get("location")).toBe("/favicon.svg");
150
+ const legacyIcon = await fetch(`http://127.0.0.1:${port}/favicon.ico?v=test`, { redirect: "manual" });
151
+ expect(legacyIcon.status).toBe(200);
152
+ expect(legacyIcon.headers.get("content-type")).toContain("image/svg+xml");
151
153
 
152
154
  const agents = await fetch(`http://127.0.0.1:${port}/api/agents`);
153
155
  expect(agents.status).toBe(200);
@@ -0,0 +1,190 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ webSearch, resolveProviders, formatSearchResults, readPage,
4
+ type WebHttp, type EnvMap,
5
+ } from "../src/tools/websearch";
6
+
7
+ /** A scriptable HTTP stub: match by URL substring, record calls. */
8
+ function stubHttp(routes: { match: string; json?: any; text?: string; throws?: string }[]): WebHttp & { calls: string[] } {
9
+ const calls: string[] = [];
10
+ const pick = (url: string) => routes.find((r) => url.includes(r.match));
11
+ const run = (url: string) => {
12
+ calls.push(url);
13
+ const r = pick(url);
14
+ if (!r) throw new Error("no route for " + url);
15
+ if (r.throws) throw new Error(r.throws);
16
+ return r;
17
+ };
18
+ return {
19
+ calls,
20
+ async getJson(url) { const r = run(url); return r.json; },
21
+ async postJson(url) { const r = run(url); return r.json; },
22
+ async getText(url) { const r = run(url); return r.text ?? ""; },
23
+ };
24
+ }
25
+
26
+ describe("websearch · provider resolution", () => {
27
+ it("auto order with no keys: jina first, then scrape engines", () => {
28
+ const ids = resolveProviders({}).map((p) => p.id);
29
+ expect(ids[0]).toBe("jina");
30
+ expect(ids).toContain("duckduckgo");
31
+ expect(ids.indexOf("jina")).toBeLessThan(ids.indexOf("duckduckgo"));
32
+ });
33
+
34
+ it("prefers keyed providers over jina, in priority order", () => {
35
+ const ids = resolveProviders({ TAVILY_API_KEY: "k", BRAVE_API_KEY: "k" }).map((p) => p.id);
36
+ expect(ids[0]).toBe("tavily");
37
+ expect(ids[1]).toBe("brave");
38
+ expect(ids.indexOf("tavily")).toBeLessThan(ids.indexOf("jina"));
39
+ });
40
+
41
+ it("a pinned engine restricts to that provider only", () => {
42
+ expect(resolveProviders({}, "duckduckgo").map((p) => p.id)).toEqual(["duckduckgo"]);
43
+ expect(resolveProviders({ TAVILY_API_KEY: "k" }, "tavily").map((p) => p.id)).toEqual(["tavily"]);
44
+ // pinned but key missing → empty (caller reports no results)
45
+ expect(resolveProviders({}, "tavily")).toHaveLength(0);
46
+ });
47
+ });
48
+
49
+ describe("websearch · provider parsers", () => {
50
+ it("parses Tavily results + answer", async () => {
51
+ const http = stubHttp([{ match: "api.tavily.com", json: {
52
+ answer: "42 is the answer",
53
+ results: [{ title: "T1", url: "https://a.com", content: "snip a" }],
54
+ } }]);
55
+ const res = await webSearch("q", { env: { TAVILY_API_KEY: "k" }, http });
56
+ expect(res.provider).toBe("tavily");
57
+ expect(res.answer).toBe("42 is the answer");
58
+ expect(res.results[0]).toEqual({ title: "T1", url: "https://a.com", snippet: "snip a" });
59
+ });
60
+
61
+ it("parses Brave results", async () => {
62
+ const http = stubHttp([{ match: "api.search.brave.com", json: {
63
+ web: { results: [{ title: "B1", url: "https://b.com", description: "desc b" }] },
64
+ } }]);
65
+ const res = await webSearch("q", { env: { BRAVE_API_KEY: "k" }, http });
66
+ expect(res.provider).toBe("brave");
67
+ expect(res.results[0].snippet).toBe("desc b");
68
+ });
69
+
70
+ it("parses Serper organic + answerBox", async () => {
71
+ const http = stubHttp([{ match: "google.serper.dev", json: {
72
+ answerBox: { answer: "direct" },
73
+ organic: [{ title: "S1", link: "https://s.com", snippet: "snip s" }],
74
+ } }]);
75
+ const res = await webSearch("q", { env: { SERPER_API_KEY: "k" }, http });
76
+ expect(res.provider).toBe("serper");
77
+ expect(res.answer).toBe("direct");
78
+ expect(res.results[0].url).toBe("https://s.com");
79
+ });
80
+
81
+ it("parses SearXNG JSON", async () => {
82
+ const http = stubHttp([{ match: "/search?q=", json: {
83
+ results: [{ title: "X1", url: "https://x.com", content: "snip x" }],
84
+ } }]);
85
+ const res = await webSearch("q", { env: { SEARXNG_URL: "https://searx.local/" }, http });
86
+ expect(res.provider).toBe("searxng");
87
+ expect(res.results[0].title).toBe("X1");
88
+ });
89
+
90
+ it("parses keyless Jina results", async () => {
91
+ const http = stubHttp([{ match: "s.jina.ai", json: {
92
+ data: [{ title: "J1", url: "https://j.com", description: "snip j" }],
93
+ } }]);
94
+ const res = await webSearch("q", { env: {}, http });
95
+ expect(res.provider).toBe("jina");
96
+ expect(res.results[0].url).toBe("https://j.com");
97
+ });
98
+
99
+ it("scrapes DuckDuckGo HTML when pinned", async () => {
100
+ const html = `<a class="result__a" href="/l/?uddg=https%3A%2F%2Fd.com">DDG Title</a>
101
+ <a class="result__snippet">ddg snippet</a>`;
102
+ const http = stubHttp([{ match: "duckduckgo.com", text: html }]);
103
+ const res = await webSearch("q", { env: {}, http, engine: "duckduckgo" });
104
+ expect(res.provider).toBe("duckduckgo");
105
+ expect(res.results[0]).toEqual({ title: "DDG Title", url: "https://d.com", snippet: "ddg snippet" });
106
+ });
107
+ });
108
+
109
+ describe("websearch · waterfall", () => {
110
+ it("falls through a throwing provider to the next", async () => {
111
+ const errors: string[] = [];
112
+ const http = stubHttp([
113
+ { match: "api.tavily.com", throws: "tavily down" },
114
+ { match: "s.jina.ai", json: { data: [{ title: "J", url: "https://j.com", description: "s" }] } },
115
+ ]);
116
+ const res = await webSearch("q", { env: { TAVILY_API_KEY: "k" }, http, onProviderError: (p, e) => errors.push(p + ":" + e) });
117
+ expect(res.provider).toBe("jina");
118
+ expect(errors[0]).toContain("tavily");
119
+ expect(res.tried).toContain("tavily");
120
+ });
121
+
122
+ it("falls through an empty-result provider to the next", async () => {
123
+ const http = stubHttp([
124
+ { match: "s.jina.ai", json: { data: [] } },
125
+ { match: "duckduckgo.com", text: `<a class="result__a" href="https://d.com">D</a><a class="result__snippet">s</a>` },
126
+ ]);
127
+ const res = await webSearch("q", { env: {}, http });
128
+ expect(res.provider).toBe("duckduckgo");
129
+ });
130
+
131
+ it("returns an empty response listing tried providers when all fail", async () => {
132
+ const http = stubHttp([
133
+ { match: "s.jina.ai", throws: "x" },
134
+ { match: "duckduckgo", throws: "x" }, { match: "bing", throws: "x" },
135
+ { match: "baidu", throws: "x" }, { match: "sogou", throws: "x" },
136
+ ]);
137
+ const res = await webSearch("q", { env: {}, http });
138
+ expect(res.results).toHaveLength(0);
139
+ expect(res.tried).toEqual(["jina", "duckduckgo", "bing", "baidu", "sogou"]);
140
+ expect(formatSearchResults(res)).toContain("No search results");
141
+ });
142
+
143
+ it("rejects an empty query", async () => {
144
+ await expect(webSearch(" ", {})).rejects.toThrow(/query/);
145
+ });
146
+ });
147
+
148
+ describe("websearch · formatting + dedup", () => {
149
+ it("formats answer and numbered results", () => {
150
+ const out = formatSearchResults({ provider: "tavily", answer: "A", results: [
151
+ { title: "T", url: "https://t.com", snippet: "s" },
152
+ ], tried: ["tavily"] });
153
+ expect(out).toContain("Answer: A");
154
+ expect(out).toContain("1. T");
155
+ expect(out).toContain("https://t.com");
156
+ });
157
+
158
+ it("dedupes by URL and drops non-http entries", async () => {
159
+ const http = stubHttp([{ match: "s.jina.ai", json: { data: [
160
+ { title: "A", url: "https://dup.com", description: "1" },
161
+ { title: "B", url: "https://dup.com", description: "2" }, // duplicate URL
162
+ { title: "C", url: "javascript:alert(1)", description: "3" }, // non-http
163
+ ] } }]);
164
+ const res = await webSearch("q", { env: {}, http });
165
+ expect(res.results).toHaveLength(1);
166
+ expect(res.results[0].url).toBe("https://dup.com");
167
+ });
168
+ });
169
+
170
+ describe("websearch · readPage", () => {
171
+ it("uses the Jina reader and clips long output", async () => {
172
+ const http = stubHttp([{ match: "r.jina.ai", text: "X".repeat(50) }]);
173
+ const out = await readPage("https://news.com/article", { env: {}, http, maxChars: 10 });
174
+ expect(http.calls[0]).toContain("r.jina.ai");
175
+ expect(out).toContain("truncated");
176
+ });
177
+
178
+ it("falls back to a raw fetch when the reader fails", async () => {
179
+ const http = stubHttp([
180
+ { match: "r.jina.ai", throws: "reader down" },
181
+ { match: "raw.com", text: "<html><body>hello <b>world</b></body></html>" },
182
+ ]);
183
+ const out = await readPage("https://raw.com/p", { env: {}, http });
184
+ expect(out).toContain("hello world");
185
+ });
186
+
187
+ it("rejects a non-http url", async () => {
188
+ await expect(readPage("ftp://x", {})).rejects.toThrow(/http/);
189
+ });
190
+ });