tool-webfetch.test.ts 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596
  1. import { describe, expect, test } from "bun:test"
  2. import { Duration, Effect, Fiber, Layer, Schema } from "effect"
  3. import * as TestClock from "effect/testing/TestClock"
  4. import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
  5. import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
  6. import { LayerNode } from "@opencode-ai/util/effect/layer-node"
  7. import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
  8. import { Permission } from "@opencode-ai/core/permission"
  9. import { Session } from "@opencode-ai/core/session"
  10. import { Tool } from "@opencode-ai/core/tool"
  11. import { WebFetchTool } from "@opencode-ai/core/tool/plugin/webfetch"
  12. import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
  13. import { Image } from "@opencode-ai/core/image"
  14. import { testEffect } from "./lib/effect"
  15. import { imagePassthrough } from "./lib/image"
  16. import { permissionLayer } from "./lib/permission"
  17. import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
  18. const webFetchToolNode = makeLocationNode({
  19. name: "test/webfetch-tool-plugin",
  20. layer: Layer.effectDiscard(registerToolPlugin(WebFetchTool.Plugin)),
  21. deps: [Tool.node, Permission.node, LayerNodePlatform.httpClient],
  22. })
  23. const sessionID = Session.ID.make("ses_webfetch_test")
  24. const requests: Array<{ readonly url: string; readonly headers: Record<string, string> }> = []
  25. const assertions: Permission.AssertInput[] = []
  26. let respond = (_request: HttpClientRequest.HttpClientRequest) =>
  27. Effect.succeed(new Response("hello", { headers: { "content-type": "text/plain" } }))
  28. const http = Layer.succeed(
  29. HttpClient.HttpClient,
  30. HttpClient.make((request) =>
  31. Effect.sync(() => requests.push({ url: request.url, headers: request.headers })).pipe(
  32. Effect.andThen(respond(request)),
  33. Effect.map((response) => HttpClientResponse.fromWeb(request, response)),
  34. ),
  35. ),
  36. )
  37. const permission = permissionLayer({ assert: (input) => Effect.sync(() => assertions.push(input)) })
  38. const toolLayer = (replacements: LayerNode.Replacements = []) =>
  39. AppNodeBuilder.build(LayerNode.group([Tool.node, webFetchToolNode]), [
  40. [Permission.node, permission],
  41. [Image.node, imagePassthrough],
  42. ...replacements,
  43. ])
  44. const it = testEffect(toolLayer([[LayerNodePlatform.httpClient, http]]))
  45. const live = testEffect(toolLayer())
  46. const reset = () => {
  47. requests.length = 0
  48. assertions.length = 0
  49. respond = () => Effect.succeed(new Response("hello", { headers: { "content-type": "text/plain" } }))
  50. }
  51. const call = (input: typeof WebFetchTool.Input.Type, id = "call-webfetch") => ({
  52. sessionID,
  53. ...toolIdentity,
  54. call: { type: "tool-call" as const, id, name: "webfetch", input },
  55. })
  56. describe("WebFetchTool helpers", () => {
  57. test("defaults format and rejects invalid timeout controls", () => {
  58. const decode = Schema.decodeUnknownSync(WebFetchTool.Input)
  59. expect(decode({ url: "https://example.com" })).toEqual({ url: "https://example.com", format: "markdown" })
  60. expect(() => decode({ url: "https://example.com", timeout: 0 })).toThrow()
  61. expect(() => decode({ url: "https://example.com", timeout: WebFetchTool.MAX_TIMEOUT_SECONDS + 1 })).toThrow()
  62. })
  63. test("ports HTML text and markdown conversions without active content", () => {
  64. const html =
  65. "<h1>Hello</h1><script>bad()</script><p>world <strong>wide</strong> <product-name>today</product-name></p><style>.bad {}</style>"
  66. expect(WebFetchTool.extractTextFromHTML(html)).toBe("Helloworld wide today")
  67. expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe("# Hello\n\nworld **wide** today")
  68. })
  69. test("renders headings, inline semantics, links, images, breaks, and thematic breaks", () => {
  70. const html = `<h2>Read <em>this</em></h2><p><a href="https://example.com/a (b)" title="Example">docs</a><br><img src="diagram.png" alt="a ] b"></p><hr><p><del>old</del></p>`
  71. expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
  72. `## Read *this*\n\n[docs](https://example.com/a%20\\(b\\) "Example") \n![a \\] b](diagram.png)\n\n---\n\n~~old~~`,
  73. )
  74. })
  75. test("preserves inline and preformatted code verbatim with safe fences", () => {
  76. const html = `<p>Use <code>say(\`hello\`)</code> now.</p><pre><code class="language-ts">const fence = \`\`\`\n&amp; stays decoded</code></pre>`
  77. expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
  78. `Use \`\`say(\`hello\`)\`\` now.\n\n~~~ts\nconst fence = \`\`\`\n& stays decoded\n~~~`,
  79. )
  80. })
  81. test("keeps nested ordered and unordered lists structurally readable", () => {
  82. const html = `<ol start="3"><li>alpha<ul><li>nested <strong>item</strong></li></ul></li><li><p>beta first</p><p>beta second</p></li></ol>`
  83. expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
  84. `3. alpha\n\n - nested **item**\n\n4. beta first\n\n beta second`,
  85. )
  86. })
  87. test("renders blockquotes and tables as readable Markdown", () => {
  88. const html = `<blockquote><p>quoted <em>text</em></p><ul><li>point</li></ul></blockquote><table><thead><tr><th>Name</th><th>Value</th></tr></thead><tbody><tr><td>one</td><td><code>1</code></td></tr></tbody></table>`
  89. expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
  90. `> quoted *text*\n\n> - point\n\n| Name | Value |\n| --- | --- |\n| one | \`1\` |`,
  91. )
  92. })
  93. test("decodes entities and normalizes prose whitespace without joining words", () => {
  94. const html = `<p>alpha\n <span>&amp; beta</span> <unknown>caf&eacute;</unknown>&nbsp;gamma 😀</p><p>delta</p>`
  95. expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(`alpha & beta café gamma 😀\n\ndelta`)
  96. })
  97. test("omits active and fallback content while retaining surrounding prose", () => {
  98. const html = `<p>before <script><b>bad</b></script><style>bad</style><noscript>bad</noscript><iframe>bad</iframe><object>bad</object><embed src="bad"><meta content="bad"><link href="bad"><template>bad</template> after</p>`
  99. expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe("before after")
  100. })
  101. test("is deterministic and bounded for malformed maximum-size input", () => {
  102. const html = `<main><p>${"visible &amp; text ".repeat(250_000)}</main></p></unknown>`
  103. const first = WebFetchTool.convertHTMLToMarkdown(html)
  104. expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(first)
  105. expect(first.startsWith("visible & text visible & text")).toBe(true)
  106. expect(first.length).toBeLessThanOrEqual(html.length)
  107. })
  108. test("bounds deeply nested list output and fragmented code fences", () => {
  109. const lists = `${"<ul><li>item".repeat(2_000)}${"</li></ul>".repeat(2_000)}`
  110. const quotes = `${"<blockquote><p>item".repeat(2_000)}${"</p></blockquote>".repeat(2_000)}`
  111. const code = `<pre>${"` x ".repeat(250_000)}</pre>`
  112. expect(WebFetchTool.convertHTMLToMarkdown(lists).length).toBeLessThan(lists.length * 4)
  113. expect(WebFetchTool.convertHTMLToMarkdown(quotes).length).toBeLessThan(quotes.length * 4)
  114. expect(() => WebFetchTool.convertHTMLToMarkdown(code)).not.toThrow()
  115. expect(
  116. WebFetchTool.convertHTMLToMarkdown(
  117. "<div>".repeat(20_000) + "safe<script><b>bad</b>&amp;</script><p>tail &amp;</p>",
  118. ),
  119. ).toBe("safe tail &")
  120. })
  121. test("escapes prose that would otherwise become Markdown structure", () => {
  122. expect(WebFetchTool.convertHTMLToMarkdown(`<p># heading</p><p>1. item</p><p>---</p><p>a | b</p>`)).toBe(
  123. `\\# heading\n\n1\\. item\n\n\\---\n\na \\| b`,
  124. )
  125. })
  126. test("preserves code whitespace and quotes every line of multiline blocks", () => {
  127. const html = `<blockquote><pre>line \n\n\nnext</pre><table><tr><td>a|b</td><td>c</td></tr></table></blockquote>`
  128. expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
  129. `> \`\`\`\n> line \n> \n> \n> next\n> \`\`\`\n\n> | a\\|b | c |\n> | --- | --- |`,
  130. )
  131. })
  132. test("keeps nested blockquotes inside their outer quote", () => {
  133. const html = `<blockquote><p>outer</p><blockquote><p>inner</p></blockquote><p>end</p></blockquote>`
  134. expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(`> outer\n>\n> > inner\n>\n> end`)
  135. })
  136. test("keeps visible whitespace around inline emphasis", () => {
  137. expect(WebFetchTool.convertHTMLToMarkdown(`<p>a<strong> b</strong> c a <em>b </em>c</p>`)).toBe(`a **b** c a *b* c`)
  138. expect(WebFetchTool.convertHTMLToMarkdown(`a<strong> </strong>b a<em> </em>b`)).toBe(`a b a b`)
  139. })
  140. test("captures formatting elements inside preformatted content as code only", () => {
  141. expect(WebFetchTool.convertHTMLToMarkdown(`<pre><b>x</b><i>y</i><del>z</del></pre>`)).toBe(`\`\`\`\nxyz\n\`\`\``)
  142. })
  143. test("normalizes multiline table cells without changing their columns", () => {
  144. const html = `<table><tr><td>x<br>y</td><td><code>a|b</code></td><td><p>first</p><p>second</p></td></tr></table>`
  145. expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(`| x y | \`a\\|b\` | first second |\n| --- | --- | --- |`)
  146. })
  147. test("flattens nested tables without corrupting the outer table", () => {
  148. const html = `<table><tr><th>Parent</th><th>Sibling</th></tr><tr><td>Before<table><tr><th>Key</th><th>Value</th></tr><tr><td>A</td><td>1</td></tr></table>After</td><td>Tail</td></tr></table>`
  149. expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
  150. `| Parent | Sibling |\n| --- | --- |\n| Before Key Value A 1 After | Tail |`,
  151. )
  152. })
  153. test("preserves loose text around malformed table rows", () => {
  154. expect(WebFetchTool.convertHTMLToMarkdown(`<table>before<tr><td>cell</td></tr>after</table>`)).toBe(
  155. `before after\n\n| cell |\n| --- |`,
  156. )
  157. expect(WebFetchTool.convertHTMLToMarkdown(`<table>alpha</table>`)).toBe(`alpha`)
  158. })
  159. test("escapes tilde fences and removes empty emphasis markers", () => {
  160. expect(WebFetchTool.convertHTMLToMarkdown(`<p>~~~</p><p><strong></strong>content</p><p>~~~</p>`)).toBe(
  161. `\\~\\~\\~\n\ncontent\n\n\\~\\~\\~`,
  162. )
  163. })
  164. test("parses malformed tag prefixes in linear time without a regex prepass", () => {
  165. const small = "<a".repeat(250_000)
  166. const large = "<a".repeat(1_000_000)
  167. const start = Bun.nanoseconds()
  168. WebFetchTool.convertHTMLToMarkdown(small)
  169. const smallDuration = Bun.nanoseconds() - start
  170. const next = Bun.nanoseconds()
  171. WebFetchTool.convertHTMLToMarkdown(large)
  172. const largeDuration = Bun.nanoseconds() - next
  173. expect(largeDuration).toBeLessThan(smallDuration * 10)
  174. })
  175. test("caps escaped prose and backtick-heavy pre output at the webfetch response ceiling", () => {
  176. const prose = `<p>${"*".repeat(WebFetchTool.MAX_RESPONSE_BYTES)}</p>`
  177. const code = `<pre>${"`".repeat(WebFetchTool.MAX_RESPONSE_BYTES - 11)}</pre>`
  178. const proseOutput = WebFetchTool.convertHTMLToMarkdown(prose)
  179. const codeOutput = WebFetchTool.convertHTMLToMarkdown(code)
  180. expect(Buffer.byteLength(proseOutput)).toBeLessThanOrEqual(WebFetchTool.MAX_RESPONSE_BYTES)
  181. expect(Buffer.byteLength(codeOutput)).toBeLessThanOrEqual(WebFetchTool.MAX_RESPONSE_BYTES)
  182. expect(codeOutput.startsWith("~~~\n")).toBe(true)
  183. })
  184. test("does not confuse source NUL text with buffered code", () => {
  185. expect(WebFetchTool.convertHTMLToMarkdown(`<p>before \u00000\u0000 after</p><pre>code</pre>`)).toBe(
  186. `before \u00000\u0000 after\n\n\`\`\`\ncode\n\`\`\``,
  187. )
  188. })
  189. test("preserves multiline inline code verbatim", () => {
  190. expect(WebFetchTool.convertHTMLToMarkdown(`<p><code>first\n\n\nsecond </code></p>`)).toBe(
  191. "` first\n\n\nsecond `",
  192. )
  193. })
  194. test("prefixes inline code at the start of a blockquote line", () => {
  195. expect(WebFetchTool.convertHTMLToMarkdown(`<blockquote><code>x</code> y</blockquote>`)).toBe(`> \`x\` y`)
  196. })
  197. test("keeps links nested in inline code associated with their text", () => {
  198. const html = `<dl><dt><code>socket = new <a href="#constructor">WebSocket</a>(url)</code><dd>Creates one.</dl>`
  199. expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
  200. `**\` socket = new \`[\`WebSocket\`](#constructor)\`(url)\`**\n: Creates one.`,
  201. )
  202. expect(WebFetchTool.convertHTMLToMarkdown(`<code><a href="#x">x</a></code> after`)).toBe(`[\`x\`](#x) after`)
  203. expect(
  204. WebFetchTool.convertHTMLToMarkdown(
  205. `<dl><dt><code><var>socket</var> = new <code><a href="#constructor">WebSocket</a></code>(<var>url</var>)</code><dd>Creates one.</dl>`,
  206. ),
  207. ).toBe(`**\` socket = new \`[\`WebSocket\`](#constructor)\`(url)\`**\n: Creates one.`)
  208. expect(WebFetchTool.convertHTMLToMarkdown(`<code>a<a href="/x">b<a href="/y">c</a>d</a>e</code>`)).toBe(
  209. `\`a\`[\`b\`](\/x)[\`c\`](\/y)\`de\``,
  210. )
  211. expect(WebFetchTool.convertHTMLToMarkdown(`<code>a<a href="/x">b</code>c`)).toBe(`\`a\`[\`b\`](\/x)c`)
  212. expect(WebFetchTool.convertHTMLToMarkdown(`<code>a<a href="/x"><div>b</div>c</a>d</code>`)).toBe(
  213. `\`a\`[](\/x)\n\n\`bcd\``,
  214. )
  215. })
  216. test("indents nested list continuations and preserves ordered numbering", () => {
  217. const html = `<ol start="0"><li value="4"><p>first</p><p>continued</p><ul><li><p>nested</p><p>continued nested</p></li></ul></li><li>next</li></ol>`
  218. expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
  219. `4. first\n\n continued\n\n - nested\n\n continued nested\n\n5. next`,
  220. )
  221. })
  222. test("renders block content outside link syntax", () => {
  223. expect(WebFetchTool.convertHTMLToMarkdown(`<a href="/docs">before<div>block</div>after</a>`)).toBe(
  224. `[before](/docs)\n\nblock\n\n[after](/docs)`,
  225. )
  226. })
  227. test("recovers nested anchors without unmatched Markdown syntax", () => {
  228. expect(WebFetchTool.convertHTMLToMarkdown(`<a href="/a">x<a href="/b">y</a>z</a>`)).toBe(`[x](/a)[y](/b)z`)
  229. })
  230. test("keeps emphasis whitespace through neutral wrappers", () => {
  231. expect(WebFetchTool.convertHTMLToMarkdown(`<p>a<strong><span> bold</span></strong>c</p>`)).toBe(`a **bold** c`)
  232. })
  233. test("flattens preformatted content inside table cells", () => {
  234. const html = `<table><tr><td><pre>a|b\nnext</pre></td><td><code>x|y</code></td></tr></table>`
  235. expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(`| a\\|b next | \`x\\|y\` |\n| --- | --- |`)
  236. })
  237. test("keeps each near-boundary inline construct closed and UTF-8-safe", () => {
  238. const payload = "😀".repeat(WebFetchTool.MAX_RESPONSE_BYTES / 4)
  239. const cases = [
  240. [`<strong>${payload}</strong>`, /^\*\*[\s\S]*\*\*$/],
  241. [`<a href="/docs">${payload}</a>`, /^\[[\s\S]*\]\(\/docs\)$/],
  242. [`<img src="image.png" alt="${payload}">`, /^!\[[\s\S]*\]\(image\.png\)$/],
  243. [`<code>${payload}</code>`, /^`[\s\S]*`$/],
  244. ] as const
  245. for (const [html, pattern] of cases) {
  246. const output = WebFetchTool.convertHTMLToMarkdown(html)
  247. expect(Buffer.byteLength(output)).toBeLessThanOrEqual(WebFetchTool.MAX_RESPONSE_BYTES)
  248. expect(output).not.toContain("�")
  249. expect(output).toMatch(pattern)
  250. }
  251. })
  252. test("keeps near-boundary block constructs syntactically complete", () => {
  253. const payload = "x".repeat(WebFetchTool.MAX_RESPONSE_BYTES)
  254. const table = WebFetchTool.convertHTMLToMarkdown(
  255. `<table><tr><th>Name</th></tr><tr><td>${payload}</td></tr></table>`,
  256. )
  257. const list = WebFetchTool.convertHTMLToMarkdown(`<ul><li>${payload}</li></ul><ul><li>nested</li></ul>`)
  258. const code = WebFetchTool.convertHTMLToMarkdown(`<pre>${payload}</pre>`)
  259. for (const output of [table, list, code]) {
  260. expect(Buffer.byteLength(output)).toBeLessThanOrEqual(WebFetchTool.MAX_RESPONSE_BYTES)
  261. expect(output).not.toContain("�")
  262. }
  263. expect(table).toMatch(/^\| Name \|\n\| --- \|\n\| [\s\S]* \|$/)
  264. expect(list).toMatch(/^- [\s\S]*$/)
  265. expect(list.includes("nested")).toBe(false)
  266. expect(code.match(/^(`{3,}|~{3,})$/gm)).toHaveLength(2)
  267. })
  268. test("keeps quoted code within budget with a safe closed fence", () => {
  269. const html = `<blockquote><pre>${"`".repeat(32)}${"~".repeat(32)}${"x".repeat(WebFetchTool.MAX_RESPONSE_BYTES)}</pre></blockquote>`
  270. const output = WebFetchTool.convertHTMLToMarkdown(html)
  271. expect(Buffer.byteLength(output)).toBeLessThanOrEqual(WebFetchTool.MAX_RESPONSE_BYTES)
  272. const lines = output.split("\n")
  273. expect(lines[0]).toMatch(/^> (`{33}|~{33})$/)
  274. expect(lines.at(-1)).toBe(lines[0])
  275. })
  276. test("separates reconstructed tables from adjacent inline and quoted content", () => {
  277. const html = `intro<table><tr><td>x</td></tr></table>outro<blockquote>quote<table><tr><td>cell</td></tr></table></blockquote><ul><li>item<table><tr><td>cell</td></tr></table></li></ul>`
  278. expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
  279. `intro\n\n| x |\n| --- |\n\noutro\n\n> quote\n\n> | cell |\n> | --- |\n\n- item\n\n| cell |\n| --- |`,
  280. )
  281. })
  282. test("keeps multiline quoted code closed at the content budget", () => {
  283. const html = `<blockquote><pre>${"x\n".repeat(WebFetchTool.MAX_RESPONSE_BYTES / 2)}</pre></blockquote><p>tail</p>`
  284. const output = WebFetchTool.convertHTMLToMarkdown(html)
  285. expect(Buffer.byteLength(output)).toBeLessThanOrEqual(WebFetchTool.MAX_RESPONSE_BYTES)
  286. expect((output.match(/(`{3}|~{3})/g) ?? []).length).toBe(2)
  287. expect(output.includes("\uFFFD")).toBe(false)
  288. expect(output.endsWith("tail")).toBe(true)
  289. })
  290. test("keeps active content suppressed when depth fallback begins", () => {
  291. const html = `<object>${"<div>".repeat(10_001)}LEAK${"</div>".repeat(10_001)}</object><p>visible</p>`
  292. expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe("visible")
  293. })
  294. test("keeps visible text after depth fallback begins inside preformatted content", () => {
  295. const html = `<pre>${"<i>".repeat(10_001)}visible${"</i>".repeat(10_001)}</pre><p>after</p>`
  296. expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe("visible after")
  297. })
  298. test("resumes links around every block structure", () => {
  299. const html = `<a href="/x">before<blockquote><p>quote</p></blockquote><ul><li>item</li></ul><pre>code</pre><table><tr><td>cell</td></tr></table>after</a>`
  300. expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
  301. `[before](/x)\n\n> quote\n\n- item\n\n\`\`\`\ncode\n\`\`\`\n\n| cell |\n| --- |\n\n[after](/x)`,
  302. )
  303. })
  304. test("indents child lists from the actual parent marker width", () => {
  305. expect(WebFetchTool.convertHTMLToMarkdown(`<ol start="100"><li>outer<ul><li>inner</li></ul></li></ol>`)).toBe(
  306. `100. outer\n\n - inner`,
  307. )
  308. })
  309. test("renders captions and definition lists with readable boundaries", () => {
  310. const html = `<table><caption>Cache modes</caption><tr><th>Name</th><th>Meaning</th></tr><tr><td>A</td><td>Local</td></tr></table><dl><dt>Cache</dt><dd>A local store</dd><dt>Origin</dt><dd>The remote source</dd></dl>`
  311. expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
  312. `Cache modes\n\n| Name | Meaning |\n| --- | --- |\n| A | Local |\n\n**Cache**\n: A local store\n\n**Origin**\n: The remote source`,
  313. )
  314. })
  315. test("falls back to row-oriented text for table spans", () => {
  316. const html = `<table><tr><th colspan="2">Group</th></tr><tr><td>A</td><td rowspan="2">Shared</td></tr><tr><td>B</td></tr></table>`
  317. expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(`Group\n\nA | Shared\n\nB`)
  318. })
  319. test("suppresses head and hidden subtrees while retaining visible body content", () => {
  320. const html = `<head><title>noise</title></head><body><p>visible</p><div hidden>hidden</div><div aria-hidden="true">aria</div><div aria-hidden="false">shown</div></body>`
  321. expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(`visible\n\nshown`)
  322. })
  323. test("preserves pre breaks and normalizes multiline link titles", () => {
  324. const html = `<pre>first<br>second</pre><p><a href="/x" title="line one\n line two">link</a></p>`
  325. expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
  326. `\`\`\`\nfirst\nsecond\n\`\`\`\n\n[link](/x "line one line two")`,
  327. )
  328. })
  329. test("renders closed and open details according to visibility", () => {
  330. const html = `<details><summary>Closed</summary><p>secret</p></details><details open><summary>Open</summary><p>visible</p></details>`
  331. expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(`Closed\n\nOpen\n\nvisible`)
  332. })
  333. })
  334. describe("WebFetchTool registration", () => {
  335. it.effect("registers and fetches an ordinary hostname HTTP URL without rewriting it", () =>
  336. Effect.gen(function* () {
  337. reset()
  338. const registry = yield* Tool.Service
  339. const url = "http://example.com/public"
  340. expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["webfetch", "execute"])
  341. expect(yield* executeTool(registry, call({ url, format: "text", timeout: 4 }))).toEqual({
  342. status: "completed",
  343. output: { url, contentType: "text/plain", format: "text", output: "hello" },
  344. content: [{ type: "text", text: "hello" }],
  345. metadata: { contentType: "text/plain" },
  346. })
  347. expect(assertions).toMatchObject([
  348. { sessionID, action: "webfetch", resources: [url], save: ["*"], metadata: { url, format: "text", timeout: 4 } },
  349. ])
  350. expect(requests).toMatchObject([{ url, headers: { accept: expect.stringContaining("text/plain;q=1.0") } }])
  351. }),
  352. )
  353. it.effect("accepts localhost URLs with the same requested-URL permission check", () =>
  354. Effect.gen(function* () {
  355. reset()
  356. const registry = yield* Tool.Service
  357. const url = "http://localhost/private"
  358. expect(yield* executeTool(registry, call({ url, format: "text" }))).toMatchObject({
  359. status: "completed",
  360. content: [{ type: "text", text: "hello" }],
  361. })
  362. expect(assertions).toMatchObject([
  363. { sessionID, action: "webfetch", resources: [url], save: ["*"], metadata: { url, format: "text" } },
  364. ])
  365. expect(requests.map((request) => request.url)).toEqual([url])
  366. }),
  367. )
  368. live.effect("follows redirects while approving only the requested URL", () =>
  369. Effect.acquireUseRelease(
  370. Effect.sync(() =>
  371. Bun.serve({
  372. port: 0,
  373. fetch: (request) =>
  374. new URL(request.url).pathname === "/redirect"
  375. ? new Response("", { status: 302, headers: { location: "/target" } })
  376. : new Response("redirected", { headers: { "content-type": "text/plain" } }),
  377. }),
  378. ),
  379. (server) =>
  380. Effect.gen(function* () {
  381. reset()
  382. const registry = yield* Tool.Service
  383. const url = new URL("/redirect", server.url).toString()
  384. expect(yield* executeTool(registry, call({ url, format: "text" }))).toMatchObject({
  385. status: "completed",
  386. content: [{ type: "text", text: "redirected" }],
  387. })
  388. expect(assertions).toMatchObject([
  389. { sessionID, action: "webfetch", resources: [url], save: ["*"], metadata: { url, format: "text" } },
  390. ])
  391. }),
  392. (server) => Effect.promise(() => server.stop(true)),
  393. ),
  394. )
  395. it.effect("rejects non-HTTP schemes before permission or transport", () =>
  396. Effect.gen(function* () {
  397. reset()
  398. const registry = yield* Tool.Service
  399. // toSessionError unwraps the "Unable to fetch <url>" ToolFailure to its cause message.
  400. expect(yield* executeTool(registry, call({ url: "file:///etc/passwd", format: "text" }))).toEqual({
  401. status: "error",
  402. error: { type: "unknown", message: "URL must use http:// or https://" },
  403. })
  404. expect(assertions).toEqual([])
  405. expect(requests).toEqual([])
  406. }),
  407. )
  408. it.effect("converts HTML to requested markdown and text", () =>
  409. Effect.gen(function* () {
  410. reset()
  411. respond = () =>
  412. Effect.succeed(
  413. new Response("<h1>Hello</h1><p>world</p><script>bad()</script>", {
  414. headers: { "content-type": "text/html; charset=utf-8" },
  415. }),
  416. )
  417. const registry = yield* Tool.Service
  418. expect(yield* executeTool(registry, call({ url: "https://1.1.1.1", format: "markdown" }))).toMatchObject({
  419. status: "completed",
  420. content: [{ type: "text", text: "# Hello\n\nworld" }],
  421. })
  422. expect(yield* executeTool(registry, call({ url: "https://1.1.1.1", format: "text" }))).toMatchObject({
  423. status: "completed",
  424. content: [{ type: "text", text: "Helloworld" }],
  425. })
  426. }),
  427. )
  428. it.effect("converts deeply nested HTML without overflowing", () =>
  429. Effect.gen(function* () {
  430. reset()
  431. respond = () =>
  432. Effect.succeed(
  433. new Response("<div>".repeat(10_000) + "content" + "</div>".repeat(10_000), {
  434. headers: { "content-type": "text/html" },
  435. }),
  436. )
  437. const registry = yield* Tool.Service
  438. const url = "https://1.1.1.1/deep-html"
  439. expect(yield* executeTool(registry, call({ url, format: "markdown" }))).toMatchObject({
  440. status: "completed",
  441. content: [{ type: "text", text: "content" }],
  442. })
  443. }),
  444. )
  445. it.effect("rejects declared and streamed oversized bodies", () =>
  446. Effect.gen(function* () {
  447. reset()
  448. const registry = yield* Tool.Service
  449. respond = () =>
  450. Effect.succeed(
  451. new Response("small", {
  452. headers: { "content-type": "text/plain", "content-length": String(WebFetchTool.MAX_RESPONSE_BYTES + 1) },
  453. }),
  454. )
  455. expect(yield* executeTool(registry, call({ url: "https://1.1.1.1/declared", format: "text" }))).toEqual({
  456. status: "error",
  457. error: {
  458. type: "unknown",
  459. message: `Response too large (exceeds ${WebFetchTool.MAX_RESPONSE_BYTES} byte limit)`,
  460. },
  461. })
  462. respond = () =>
  463. Effect.succeed(
  464. new Response("x".repeat(WebFetchTool.MAX_RESPONSE_BYTES + 1), { headers: { "content-type": "text/plain" } }),
  465. )
  466. expect(yield* executeTool(registry, call({ url: "https://1.1.1.1/streamed", format: "text" }))).toEqual({
  467. status: "error",
  468. error: {
  469. type: "unknown",
  470. message: `Response too large (exceeds ${WebFetchTool.MAX_RESPONSE_BYTES} byte limit)`,
  471. },
  472. })
  473. }),
  474. )
  475. it.effect("keeps images and files unsupported until typed outcomes can carry attachments", () =>
  476. Effect.gen(function* () {
  477. reset()
  478. const registry = yield* Tool.Service
  479. respond = () => Effect.succeed(new Response("png", { headers: { "content-type": "image/png" } }))
  480. expect(yield* executeTool(registry, call({ url: "https://1.1.1.1/image", format: "html" }))).toEqual({
  481. status: "error",
  482. error: { type: "unknown", message: "Unsupported fetched image content type: image/png" },
  483. })
  484. respond = () => Effect.succeed(new Response("pdf", { headers: { "content-type": "application/pdf" } }))
  485. expect(yield* executeTool(registry, call({ url: "https://1.1.1.1/file", format: "html" }))).toEqual({
  486. status: "error",
  487. error: { type: "unknown", message: "Unsupported fetched file content type: application/pdf" },
  488. })
  489. }),
  490. )
  491. it.effect("retries Cloudflare challenges with an honest user agent", () =>
  492. Effect.gen(function* () {
  493. reset()
  494. let count = 0
  495. respond = () =>
  496. Effect.succeed(
  497. ++count === 1
  498. ? new Response("challenge", { status: 403, headers: { "cf-mitigated": "challenge" } })
  499. : new Response("ok", { headers: { "content-type": "text/plain" } }),
  500. )
  501. const registry = yield* Tool.Service
  502. expect(yield* executeTool(registry, call({ url: "https://1.1.1.1", format: "text" }))).toMatchObject({
  503. status: "completed",
  504. content: [{ type: "text", text: "ok" }],
  505. })
  506. expect(requests).toHaveLength(2)
  507. expect(requests[0]?.headers["user-agent"]).toContain("Mozilla/5.0")
  508. expect(requests[1]?.headers["user-agent"]).toBe("opencode")
  509. }),
  510. )
  511. it.effect("times out stalled requests", () =>
  512. Effect.gen(function* () {
  513. reset()
  514. respond = () => Effect.never
  515. const registry = yield* Tool.Service
  516. const fiber = yield* executeTool(
  517. registry,
  518. call({ url: "https://1.1.1.1/slow", format: "text", timeout: 1 }),
  519. ).pipe(Effect.forkChild)
  520. yield* TestClock.adjust(Duration.seconds(1))
  521. expect(yield* Fiber.join(fiber)).toEqual({
  522. status: "error",
  523. error: { type: "unknown", message: "Request timed out" },
  524. })
  525. }),
  526. )
  527. })