codemode.test.ts 40 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117
  1. import { describe, expect, test } from "bun:test"
  2. import { Cause, Effect, Schema } from "effect"
  3. import { CodeMode, Tool, toolError } from "../src/index.js"
  4. const run = (tool: Tool.Tool<never>) =>
  5. Effect.runPromise(CodeMode.make({ tools: { host: { call: tool } } }).execute("return await tools.host.call({})"))
  6. class UnsafeHostError extends Schema.TaggedErrorClass<UnsafeHostError>()("UnsafeHostError", {
  7. reason: Schema.String,
  8. }) {}
  9. describe("CodeMode host failure boundary", () => {
  10. test("preserves explicit safe tool failures", async () => {
  11. const result = await run(
  12. Tool.make({
  13. description: "Fail safely",
  14. input: Schema.Struct({}),
  15. output: Schema.String,
  16. execute: () => Effect.fail(toolError("Authorized request was refused")),
  17. }),
  18. )
  19. expect(result.ok ? undefined : result.error).toStrictEqual({
  20. kind: "ToolFailure",
  21. message: "Authorized request was refused",
  22. })
  23. })
  24. test("does not rewrite explicit safe tool failures", async () => {
  25. const result = await run(
  26. Tool.make({
  27. description: "Fail safely",
  28. input: Schema.Struct({}),
  29. output: Schema.String,
  30. execute: () => Effect.fail(toolError("File not found: /tmp/report.json")),
  31. }),
  32. )
  33. expect(result.ok ? undefined : result.error).toStrictEqual({
  34. kind: "ToolFailure",
  35. message: "File not found: /tmp/report.json",
  36. })
  37. })
  38. test("sanitizes unknown host failures and defects", async () => {
  39. for (const failure of [
  40. Effect.fail(new UnsafeHostError({ reason: "Authorization: Bearer typed-secret" })),
  41. Effect.die(new Error("postgres://user:defect-secret@example.invalid")),
  42. ]) {
  43. const result = await run(
  44. Tool.make({
  45. description: "Fail internally",
  46. input: Schema.Struct({}),
  47. output: Schema.String,
  48. execute: () => failure,
  49. }),
  50. )
  51. expect(result.ok ? undefined : result.error).toStrictEqual({
  52. kind: "ToolFailure",
  53. message: "Tool execution failed",
  54. })
  55. expect(JSON.stringify(result)).not.toMatch(/typed-secret|defect-secret|Authorization: Bearer/)
  56. }
  57. })
  58. test("sanitizes invalid host output", async () => {
  59. const secret = "invalid-output-secret"
  60. const result = await run(
  61. Tool.make({
  62. description: "Return invalid output",
  63. input: Schema.Struct({}),
  64. output: Schema.Struct({ safe: Schema.String }),
  65. execute: () => Effect.succeed({ safe: 1, secret } as unknown as { readonly safe: string }),
  66. }),
  67. )
  68. expect(result.ok ? undefined : result.error).toStrictEqual({
  69. kind: "InvalidToolOutput",
  70. message: "Invalid output from tool 'host.call'.",
  71. })
  72. expect(JSON.stringify(result)).not.toMatch(/invalid-output-secret/)
  73. })
  74. test("sanitizes host output that throws while being copied", async () => {
  75. const result = await run(
  76. Tool.make({
  77. description: "Return hostile output",
  78. input: Schema.Struct({}),
  79. output: Schema.Unknown,
  80. execute: () =>
  81. Effect.succeed(
  82. new Proxy(
  83. {},
  84. {
  85. ownKeys: () => {
  86. throw new Error("host-output-secret")
  87. },
  88. },
  89. ),
  90. ),
  91. }),
  92. )
  93. expect(result.ok ? undefined : result.error).toStrictEqual({
  94. kind: "InvalidToolOutput",
  95. message: "Invalid output from tool 'host.call'.",
  96. })
  97. expect(JSON.stringify(result)).not.toMatch(/host-output-secret/)
  98. })
  99. test("caught tool failures are Error values in-program", async () => {
  100. const result = await Effect.runPromise(
  101. CodeMode.make({
  102. tools: {
  103. host: {
  104. call: Tool.make({
  105. description: "Refuse",
  106. input: Schema.Struct({}),
  107. output: Schema.String,
  108. execute: () => Effect.fail(toolError("Refused")),
  109. }),
  110. },
  111. },
  112. }).execute(`
  113. try {
  114. await tools.host.call({})
  115. return "no"
  116. } catch (e) {
  117. return { isError: e instanceof Error, message: e.message }
  118. }
  119. `),
  120. )
  121. expect(result.ok).toBe(true)
  122. if (result.ok) expect(result.value).toStrictEqual({ isError: true, message: "Refused" })
  123. })
  124. test("propagates host interruption instead of returning a diagnostic", async () => {
  125. const exit = await Effect.runPromiseExit(
  126. CodeMode.make({
  127. tools: {
  128. host: {
  129. call: Tool.make({
  130. description: "Interrupt",
  131. input: Schema.Struct({}),
  132. output: Schema.String,
  133. execute: () => Effect.interrupt,
  134. }),
  135. },
  136. },
  137. }).execute("return await tools.host.call({})"),
  138. )
  139. expect(exit._tag).toBe("Failure")
  140. if (exit._tag === "Failure") {
  141. expect(Cause.hasInterruptsOnly(exit.cause)).toBe(true)
  142. }
  143. })
  144. })
  145. describe("CodeMode tool-call observation", () => {
  146. test("reports the tools actually invoked with decoded input", async () => {
  147. const calls: Array<unknown> = []
  148. const lookup = Tool.make({
  149. description: "Look up a value",
  150. input: Schema.Struct({ query: Schema.String }),
  151. output: Schema.String,
  152. execute: ({ query }) => Effect.succeed(query),
  153. })
  154. const result = await Effect.runPromise(
  155. CodeMode.make({
  156. tools: { context: { lookup } },
  157. onToolCallStart: (call) => Effect.sync(() => calls.push(call)),
  158. }).execute(`
  159. if (false) await tools.context.lookup({ query: "not called" })
  160. return await tools.context.lookup({ query: "deployment failure" })
  161. `),
  162. )
  163. expect(result.ok).toBe(true)
  164. expect(calls).toStrictEqual([{ index: 0, name: "context.lookup", input: { query: "deployment failure" } }])
  165. })
  166. test("observes settled calls with outcome and duration", async () => {
  167. const events: Array<{ phase: string; index: number; name: string; outcome?: string; message?: string }> = []
  168. const lookup = Tool.make({
  169. description: "Look up a value",
  170. input: Schema.Struct({ query: Schema.String }),
  171. output: Schema.String,
  172. execute: ({ query }) => (query === "boom" ? Effect.fail(toolError("Lookup refused")) : Effect.succeed(query)),
  173. })
  174. const runtime = CodeMode.make({
  175. tools: { context: { lookup } },
  176. onToolCallStart: (call) =>
  177. Effect.sync(() => {
  178. events.push({ phase: "start", index: call.index, name: call.name })
  179. }),
  180. onToolCallEnd: (call) =>
  181. Effect.sync(() => {
  182. expect(call.durationMs).toBeGreaterThanOrEqual(0)
  183. events.push({
  184. phase: "end",
  185. index: call.index,
  186. name: call.name,
  187. outcome: call.outcome,
  188. ...(call.message === undefined ? {} : { message: call.message }),
  189. })
  190. }),
  191. })
  192. const success = await Effect.runPromise(runtime.execute(`return await tools.context.lookup({ query: "ok" })`))
  193. expect(success.ok).toBe(true)
  194. const failure = await Effect.runPromise(runtime.execute(`return await tools.context.lookup({ query: "boom" })`))
  195. expect(failure.ok).toBe(false)
  196. expect(events).toStrictEqual([
  197. { phase: "start", index: 0, name: "context.lookup" },
  198. { phase: "end", index: 0, name: "context.lookup", outcome: "success" },
  199. { phase: "start", index: 0, name: "context.lookup" },
  200. { phase: "end", index: 0, name: "context.lookup", outcome: "failure", message: "Lookup refused" },
  201. ])
  202. })
  203. })
  204. describe("CodeMode console capture", () => {
  205. test("captures console output as bounded result logs", async () => {
  206. const result = await Effect.runPromise(
  207. CodeMode.execute({
  208. code: `
  209. const returned = console.log("Thread info:", { name: "Demo", count: 2 })
  210. console.warn("careful")
  211. return returned
  212. `,
  213. }),
  214. )
  215. expect(result).toStrictEqual({
  216. ok: true,
  217. value: null,
  218. logs: ['Thread info: {"name":"Demo","count":2}', "[warn] careful"],
  219. toolCalls: [],
  220. })
  221. expect(Schema.decodeUnknownSync(CodeMode.Result)(JSON.parse(JSON.stringify(result)))).toStrictEqual(result)
  222. })
  223. test("keeps logs captured before failures", async () => {
  224. const result = await Effect.runPromise(
  225. CodeMode.execute({
  226. code: `
  227. console.log("before failure")
  228. throw new Error("boom")
  229. `,
  230. }),
  231. )
  232. expect(result.ok ? undefined : result.logs).toStrictEqual(["before failure"])
  233. expect(result.ok ? undefined : result.error.message).toBe("Uncaught: boom")
  234. })
  235. test("prints NaN and Infinity literally instead of the JSON null", async () => {
  236. const result = await Effect.runPromise(
  237. CodeMode.execute({
  238. code: `
  239. console.log(NaN)
  240. console.log(Infinity, -Infinity)
  241. console.log({ ratio: NaN, bounds: [Infinity] })
  242. return null
  243. `,
  244. }),
  245. )
  246. expect(result.ok).toBe(true)
  247. expect(result.logs).toStrictEqual(["NaN", "Infinity -Infinity", '{"ratio":NaN,"bounds":[Infinity]}'])
  248. })
  249. test("renders CodeMode values nested inside logged containers", async () => {
  250. const result = await Effect.runPromise(
  251. CodeMode.execute({
  252. code: `
  253. console.log({ m: new Map([["a", 1]]), when: new Date(0), r: /ab/g, s: new Set([1, 2]) })
  254. console.log([new Date(0)])
  255. return null
  256. `,
  257. }),
  258. )
  259. expect(result.ok).toBe(true)
  260. expect(result.logs).toStrictEqual([
  261. '{"m":Map(1) [["a",1]],"when":1970-01-01T00:00:00.000Z,"r":/ab/g,"s":Set(2) [1,2]}',
  262. "[1970-01-01T00:00:00.000Z]",
  263. ])
  264. })
  265. test("console formatting is total: cycles and opaque references render as markers", async () => {
  266. const result = await Effect.runPromise(
  267. CodeMode.execute({
  268. code: `
  269. const m = new Map()
  270. m.set("self", m)
  271. console.log({ box: m })
  272. console.log({ fn: (x) => x, ok: 1 })
  273. return null
  274. `,
  275. }),
  276. )
  277. expect(result.ok).toBe(true)
  278. expect(result.logs).toStrictEqual(['{"box":Map(1) [["self",[Circular]]]}', '{"fn":[opaque reference],"ok":1}'])
  279. })
  280. test("console.table renders CodeMode value cells", async () => {
  281. const result = await Effect.runPromise(
  282. CodeMode.execute({
  283. code: `
  284. console.table([{ when: new Date(0), n: NaN }])
  285. return null
  286. `,
  287. }),
  288. )
  289. expect(result.ok).toBe(true)
  290. expect(result.logs).toStrictEqual(["(index)\twhen\tn\n0\t1970-01-01T00:00:00.000Z\tNaN"])
  291. })
  292. test("captures console.dir and console.table output", async () => {
  293. const result = await Effect.runPromise(
  294. CodeMode.execute({
  295. code: `
  296. console.dir({ nested: { ok: true } })
  297. console.table([
  298. { name: "Kit", count: 1, hidden: "x" },
  299. { name: "Olive", count: 2, hidden: "y" }
  300. ], ["name", "count"])
  301. return "done"
  302. `,
  303. }),
  304. )
  305. expect(result).toStrictEqual({
  306. ok: true,
  307. value: "done",
  308. logs: ['{"nested":{"ok":true}}', "(index)\tname\tcount\n0\tKit\t1\n1\tOlive\t2"],
  309. toolCalls: [],
  310. })
  311. })
  312. })
  313. describe("CodeMode output budget", () => {
  314. test("absent maxOutputBytes means no truncation at all", async () => {
  315. const result = await Effect.runPromise(
  316. CodeMode.execute({
  317. code: `console.log("z".repeat(50_000)); return "x".repeat(100_000)`,
  318. }),
  319. )
  320. expect(result.ok).toBe(true)
  321. if (!result.ok) return
  322. expect(result.truncated).toBeUndefined()
  323. expect(result.value).toBe("x".repeat(100_000))
  324. expect(result.logs).toStrictEqual(["z".repeat(50_000)])
  325. })
  326. test("truncates an oversized result value with a marker instead of failing", async () => {
  327. const limits: CodeMode.ExecutionLimits = { maxOutputBytes: 40 }
  328. const result = await Effect.runPromise(
  329. CodeMode.execute({
  330. code: `return { data: "${"x".repeat(200)}" }`,
  331. limits,
  332. }),
  333. )
  334. expect(result.ok).toBe(true)
  335. if (!result.ok) return
  336. expect(result.truncated).toBe(true)
  337. expect(typeof result.value).toBe("string")
  338. expect(result.value).toMatch(
  339. /^\{"data":"x+ \[result truncated: \d+ bytes exceeds the 40-byte output limit; return a smaller value\]$/,
  340. )
  341. expect(Schema.decodeUnknownSync(CodeMode.Result)(JSON.parse(JSON.stringify(result)))).toStrictEqual(result)
  342. })
  343. test("keeps leading logs within the remaining budget and marks the cut", async () => {
  344. const limits: CodeMode.ExecutionLimits = { maxOutputBytes: 40 }
  345. const result = await Effect.runPromise(
  346. CodeMode.execute({
  347. code: `
  348. console.log("first line")
  349. console.log("${"y".repeat(200)}")
  350. return "ok"
  351. `,
  352. limits,
  353. }),
  354. )
  355. expect(result.ok).toBe(true)
  356. if (!result.ok) return
  357. expect(result.value).toBe("ok")
  358. expect(result.truncated).toBe(true)
  359. expect(result.logs).toStrictEqual(["first line", "[logs truncated: showing 1 of 2 lines]"])
  360. })
  361. test("does not mark results within the budget", async () => {
  362. const result = await Effect.runPromise(
  363. CodeMode.execute({
  364. code: `
  365. console.log("fits")
  366. return { fits: true }
  367. `,
  368. }),
  369. )
  370. expect(result).toStrictEqual({
  371. ok: true,
  372. value: { fits: true },
  373. logs: ["fits"],
  374. toolCalls: [],
  375. })
  376. })
  377. })
  378. describe("CodeMode schema flexibility", () => {
  379. test("accepts render-only JSON Schema input and omitted output", async () => {
  380. const observed: Array<unknown> = []
  381. const call = Tool.make({
  382. description: "Call an adapter-described tool",
  383. input: {
  384. type: "object",
  385. properties: { id: { type: "string" }, count: { type: "number" } },
  386. required: ["id"],
  387. },
  388. execute: (input) =>
  389. Effect.sync(() => {
  390. observed.push(input)
  391. return { echoed: input }
  392. }),
  393. })
  394. const runtime = CodeMode.make({ tools: { adapter: { call } } })
  395. expect(runtime.catalog()).toStrictEqual([
  396. {
  397. path: "adapter.call",
  398. description: "Call an adapter-described tool",
  399. signature: "tools.adapter.call(input: {\n id: string,\n count?: number,\n}): Promise<void>",
  400. },
  401. ])
  402. // JSON Schema is render-only: mistyped input passes through unvalidated.
  403. const result = await Effect.runPromise(runtime.execute(`return await tools.adapter.call({ id: 42 })`))
  404. expect(result.ok).toBe(true)
  405. if (result.ok) expect(result.value).toBeNull()
  406. expect(observed).toStrictEqual([{ id: 42 }])
  407. })
  408. test("outbound tool arguments follow JSON serialization semantics", async () => {
  409. const observed: Array<unknown> = []
  410. const call = Tool.make({
  411. description: "Observe raw input",
  412. input: { type: "object" },
  413. execute: (input) =>
  414. Effect.sync(() => {
  415. observed.push(input)
  416. return "ok"
  417. }),
  418. })
  419. const runtime = CodeMode.make({ tools: { adapter: { call } } })
  420. const result = await Effect.runPromise(
  421. runtime.execute(
  422. `return await tools.adapter.call({ q: undefined, limit: 0 / 0, rate: 1 / 0, items: [1, undefined, 2], holes: [1, , 3] })`,
  423. ),
  424. )
  425. expect(result.ok).toBe(true)
  426. const received = observed[0] as Record<string, unknown>
  427. expect(received).toStrictEqual({ limit: null, rate: null, items: [1, null, 2], holes: [1, null, 3] })
  428. // The undefined-valued property is dropped like JSON.stringify, not delivered as undefined.
  429. expect(Object.hasOwn(received, "q")).toBe(false)
  430. })
  431. test("dropping undefined values lets optionalKey schemas accept conditional arguments", async () => {
  432. const observed: Array<unknown> = []
  433. const find = Tool.make({
  434. description: "Find things",
  435. input: Schema.Struct({ query: Schema.optionalKey(Schema.String), limit: Schema.optionalKey(Schema.Number) }),
  436. execute: (input) =>
  437. Effect.sync(() => {
  438. observed.push(input)
  439. return "ok"
  440. }),
  441. })
  442. const runtime = CodeMode.make({ tools: { things: { find } } })
  443. // The `cond ? value : undefined` idiom: optionalKey rejects a present undefined, so the
  444. // JSON boundary must drop the key before the schema decodes.
  445. const result = await Effect.runPromise(
  446. runtime.execute(`return await tools.things.find({ query: undefined, limit: 5 })`),
  447. )
  448. expect(result.ok).toBe(true)
  449. expect(observed).toStrictEqual([{ limit: 5 }])
  450. const search = await Effect.runPromise(runtime.execute(`return (await search({ query: undefined })).items.length`))
  451. expect(search.ok).toBe(true)
  452. })
  453. test("renders JSON Schema outputs and $defs references", async () => {
  454. const lookup = Tool.make({
  455. description: "Look up a user",
  456. input: { type: "object", properties: { login: { type: "string" } }, required: ["login"] },
  457. output: {
  458. $ref: "#/$defs/User",
  459. $defs: {
  460. User: {
  461. type: "object",
  462. properties: { login: { type: "string" }, id: { type: "number" } },
  463. required: ["login", "id"],
  464. },
  465. },
  466. },
  467. execute: () => Effect.succeed({ login: "kit", id: 7 }),
  468. })
  469. const runtime = CodeMode.make({ tools: { users: { lookup } } })
  470. expect(runtime.catalog()).toStrictEqual([
  471. {
  472. path: "users.lookup",
  473. description: "Look up a user",
  474. signature: "tools.users.lookup(input: {\n login: string,\n}): Promise<{\n login: string,\n id: number,\n}>",
  475. },
  476. ])
  477. const result = await Effect.runPromise(runtime.execute(`return await tools.users.lookup({ login: "kit" })`))
  478. expect(result.ok).toBe(true)
  479. if (result.ok) expect(result.value).toStrictEqual({ login: "kit", id: 7 })
  480. })
  481. test("Effect Schema output without an input transform renders void when omitted", async () => {
  482. const ping = Tool.make({
  483. description: "Ping",
  484. input: Schema.Struct({ host: Schema.String }),
  485. execute: () => Effect.succeed("pong"),
  486. })
  487. const runtime = CodeMode.make({ tools: { net: { ping } } })
  488. expect(runtime.catalog()[0]?.signature).toBe("tools.net.ping(input: {\n host: string,\n}): Promise<void>")
  489. const result = await Effect.runPromise(runtime.execute(`return await tools.net.ping({ host: "example.test" })`))
  490. expect(result.ok).toBe(true)
  491. if (result.ok) expect(result.value).toBeNull()
  492. })
  493. })
  494. describe("CodeMode public contract", () => {
  495. const lookup = Tool.make({
  496. description: "Look up an order by ID",
  497. input: Schema.Struct({ id: Schema.String }),
  498. output: Schema.Struct({ id: Schema.String, status: Schema.String }),
  499. execute: ({ id }) => Effect.succeed({ id, status: "open" }),
  500. })
  501. const tools = { orders: { lookup } }
  502. const source = `return await tools.orders.lookup({ id: "order_42" })`
  503. test("keeps one-shot and reusable execution equivalent", async () => {
  504. const runtime = CodeMode.make({ tools })
  505. const [oneShot, reusable] = await Promise.all([
  506. Effect.runPromise(CodeMode.execute({ tools, code: source })),
  507. Effect.runPromise(runtime.execute(source)),
  508. ])
  509. expect(reusable).toStrictEqual(oneShot)
  510. const input: CodeMode.Input = { code: source }
  511. expect(Schema.decodeUnknownSync(CodeMode.Input)(input)).toStrictEqual(input)
  512. expect(Schema.decodeUnknownSync(CodeMode.Result)(JSON.parse(JSON.stringify(reusable)))).toStrictEqual(reusable)
  513. })
  514. test("a reused execution Effect starts from a clean slate", async () => {
  515. const echo = Tool.make({
  516. description: "echo",
  517. input: Schema.Struct({}),
  518. output: Schema.Number,
  519. execute: () => Effect.succeed(1),
  520. })
  521. const effect = CodeMode.execute({
  522. tools: { host: { echo } },
  523. code: `console.log("hi"); return await tools.host.echo({})`,
  524. limits: { maxToolCalls: 1 },
  525. })
  526. const first = await Effect.runPromise(effect)
  527. const second = await Effect.runPromise(effect)
  528. // Per-execution state (tool-call budget and audit list, logs, timeout bookkeeping) must
  529. // bind at run time, so the second run neither exhausts the budget nor leaks run 1's logs.
  530. expect(first).toStrictEqual(second)
  531. expect(second).toStrictEqual({ ok: true, value: 1, logs: ["hi"], toolCalls: [{ name: "host.echo" }] })
  532. })
  533. test("describes the catalog and keeps the search built-in registered", async () => {
  534. const runtime = CodeMode.make({ tools })
  535. expect(runtime.catalog()).toStrictEqual([
  536. {
  537. path: "orders.lookup",
  538. description: "Look up an order by ID",
  539. signature: "tools.orders.lookup(input: {\n id: string,\n}): Promise<{\n id: string,\n status: string,\n}>",
  540. },
  541. ])
  542. const result = await Effect.runPromise(runtime.execute(`return search({ query: "order" })`))
  543. expect(result.ok).toBe(true)
  544. if (result.ok) {
  545. expect(result.value).toStrictEqual({
  546. items: [
  547. {
  548. path: "tools.orders.lookup",
  549. description: "Look up an order by ID",
  550. signature:
  551. "tools.orders.lookup(input: {\n id: string,\n}): Promise<{\n id: string,\n status: string,\n}>",
  552. },
  553. ],
  554. remaining: 0,
  555. next: null,
  556. })
  557. }
  558. })
  559. test("renders equivalent catalogs identically regardless of tool insertion order", () => {
  560. const alpha = Tool.make({
  561. description: "Alpha tool",
  562. input: Schema.Struct({}),
  563. output: Schema.Void,
  564. execute: () => Effect.void,
  565. })
  566. const zeta = Tool.make({
  567. description: "Zeta tool",
  568. input: Schema.Struct({}),
  569. output: Schema.Void,
  570. execute: () => Effect.void,
  571. })
  572. const first = CodeMode.make({ tools: { zeta: { zeta, alpha }, alpha: { zeta, alpha } } })
  573. const second = CodeMode.make({ tools: { alpha: { alpha, zeta }, zeta: { alpha, zeta } } })
  574. expect(first.catalog()).toStrictEqual(second.catalog())
  575. expect(first.catalog().map((tool) => tool.path)).toEqual(["alpha.alpha", "alpha.zeta", "zeta.alpha", "zeta.zeta"])
  576. })
  577. test("renders bracket notation for tool names that are not JavaScript identifiers", async () => {
  578. const resolveLibrary = Tool.make({
  579. description: "Resolve a library ID",
  580. input: Schema.Struct({ libraryName: Schema.String }),
  581. output: Schema.String,
  582. execute: ({ libraryName }) => Effect.succeed(`/resolved/${libraryName}`),
  583. })
  584. const runtime = CodeMode.make({ tools: { context7: { "resolve-library-id": resolveLibrary } } })
  585. expect(runtime.catalog()).toStrictEqual([
  586. {
  587. path: "context7.resolve-library-id",
  588. description: "Resolve a library ID",
  589. signature: 'tools.context7["resolve-library-id"](input: {\n libraryName: string,\n}): Promise<string>',
  590. },
  591. ])
  592. const search = await Effect.runPromise(runtime.execute(`return search({ query: "resolve library id" })`))
  593. expect(search.ok).toBe(true)
  594. if (search.ok) {
  595. expect(search.value).toStrictEqual({
  596. items: [
  597. {
  598. path: 'tools.context7["resolve-library-id"]',
  599. description: "Resolve a library ID",
  600. signature: 'tools.context7["resolve-library-id"](input: {\n libraryName: string,\n}): Promise<string>',
  601. },
  602. ],
  603. remaining: 0,
  604. next: null,
  605. })
  606. }
  607. const call = await Effect.runPromise(
  608. runtime.execute(`return await tools.context7["resolve-library-id"]({ libraryName: "TypeScript" })`),
  609. )
  610. expect(call.ok).toBe(true)
  611. if (call.ok) expect(call.value).toBe("/resolved/TypeScript")
  612. const exact = await Effect.runPromise(
  613. runtime.execute(`return search({ query: 'tools.context7["resolve-library-id"]' })`),
  614. )
  615. expect(exact.ok).toBe(true)
  616. if (exact.ok) expect(exact.value).toMatchObject({ remaining: 0, next: null })
  617. })
  618. test("uses one ranked search returning complete tools for large catalogs", async () => {
  619. const upload = Tool.make({
  620. description: "Upload one readable local file to the current Discord thread",
  621. input: Schema.Struct({ path: Schema.String }),
  622. output: Schema.Struct({ sent: Schema.Boolean }),
  623. execute: () => Effect.succeed({ sent: true }),
  624. })
  625. const generate = Tool.make({
  626. description: "Generate an image and upload it to the current Discord thread",
  627. input: Schema.Struct({ prompt: Schema.String }),
  628. output: Schema.Struct({ sent: Schema.Boolean }),
  629. execute: () => Effect.succeed({ sent: true }),
  630. })
  631. const runtime = CodeMode.make({
  632. tools: { thread: { uploadFile: upload, generateImage: generate }, orders: { lookup } },
  633. })
  634. const result = await Effect.runPromise(
  635. runtime.execute(`
  636. return search({
  637. query: "send message attachment upload file to current Discord thread",
  638. limit: 2
  639. })
  640. `),
  641. )
  642. expect(result.ok).toBe(true)
  643. if (!result.ok) return
  644. expect(result.value).toStrictEqual({
  645. items: [
  646. {
  647. path: "tools.thread.uploadFile",
  648. description: "Upload one readable local file to the current Discord thread",
  649. signature: "tools.thread.uploadFile(input: {\n path: string,\n}): Promise<{\n sent: boolean,\n}>",
  650. },
  651. {
  652. path: "tools.thread.generateImage",
  653. description: "Generate an image and upload it to the current Discord thread",
  654. signature: "tools.thread.generateImage(input: {\n prompt: string,\n}): Promise<{\n sent: boolean,\n}>",
  655. },
  656. ],
  657. remaining: 0,
  658. next: null,
  659. })
  660. expect(result.toolCalls).toStrictEqual([{ name: "search" }])
  661. const variants = await Effect.runPromise(
  662. runtime.execute(`
  663. return [
  664. search({ query: "file" }),
  665. search({ query: "image" })
  666. ]
  667. `),
  668. )
  669. expect(variants.ok).toBe(true)
  670. if (variants.ok) {
  671. expect((variants.value as Array<{ items: Array<{ path: string }> }>)[0]?.items[0]?.path).toBe(
  672. "tools.thread.uploadFile",
  673. )
  674. expect((variants.value as Array<{ items: Array<{ path: string }> }>)[1]?.items[0]?.path).toBe(
  675. "tools.thread.generateImage",
  676. )
  677. }
  678. })
  679. test("search is a counted tool call: it burns maxToolCalls and fires the hooks", async () => {
  680. const started: Array<string> = []
  681. const ended: Array<string> = []
  682. const limited = CodeMode.make({
  683. tools,
  684. limits: { maxToolCalls: 1 },
  685. onToolCallStart: (call) => Effect.sync(() => void started.push(call.name)),
  686. onToolCallEnd: (call) => Effect.sync(() => void ended.push(`${call.name}:${call.outcome}`)),
  687. })
  688. const result = await Effect.runPromise(limited.execute(`search({}); return search({})`))
  689. expect(result.ok).toBe(false)
  690. if (!result.ok) expect(result.error.kind).toBe("ToolCallLimitExceeded")
  691. expect(started).toEqual(["search"])
  692. expect(ended).toEqual(["search:success"])
  693. })
  694. test("search is an opaque, shadowable global like other built-ins", async () => {
  695. const runtime = CodeMode.make({ tools })
  696. expect(await Effect.runPromise(runtime.execute(`return typeof search`))).toMatchObject({ value: "function" })
  697. // A program-level declaration shadows the global, as JS module scope does.
  698. const shadowed = await Effect.runPromise(runtime.execute(`const search = () => "local"; return search()`))
  699. expect(shadowed.ok).toBe(true)
  700. if (shadowed.ok) expect(shadowed.value).toBe("local")
  701. // The reference itself cannot cross the data boundary.
  702. const escaped = await Effect.runPromise(runtime.execute(`return { search }`))
  703. expect(escaped.ok).toBe(false)
  704. if (!escaped.ok) expect(escaped.error.kind).toBe("InvalidDataValue")
  705. })
  706. test("search defaults to 10 results and resolves exact tool paths", async () => {
  707. const tool = (index: number) =>
  708. Tool.make({
  709. description: `Numbered tool ${index}`,
  710. input: Schema.Struct({ id: Schema.String }),
  711. output: Schema.String,
  712. execute: () => Effect.succeed("ok"),
  713. })
  714. const runtime = CodeMode.make({
  715. tools: {
  716. many: Object.fromEntries(Array.from({ length: 14 }, (_, index) => [`tool${index}`, tool(index)])),
  717. },
  718. })
  719. const browse = await Effect.runPromise(runtime.execute(`return search({})`))
  720. expect(browse.ok).toBe(true)
  721. if (browse.ok) {
  722. const value = browse.value as {
  723. items: Array<{ path: string }>
  724. remaining: number
  725. next: { offset: number } | null
  726. }
  727. expect(value.items).toHaveLength(10)
  728. expect(value.remaining).toBe(4)
  729. expect(value.next).toStrictEqual({ offset: 10 })
  730. }
  731. for (const query of ["many.tool13", "tools.many.tool13"]) {
  732. const exact = await Effect.runPromise(runtime.execute(`return search({ query: ${JSON.stringify(query)} })`))
  733. expect(exact.ok).toBe(true)
  734. if (exact.ok) {
  735. expect(exact.value).toStrictEqual({
  736. items: [
  737. {
  738. path: "tools.many.tool13",
  739. description: "Numbered tool 13",
  740. signature: "tools.many.tool13(input: {\n id: string,\n}): Promise<string>",
  741. },
  742. ],
  743. remaining: 0,
  744. next: null,
  745. })
  746. }
  747. }
  748. })
  749. test("scopes search to one namespace and browses it alphabetically", async () => {
  750. const simple = (description: string) =>
  751. Tool.make({
  752. description,
  753. input: Schema.Struct({ id: Schema.String }),
  754. output: Schema.String,
  755. execute: () => Effect.succeed("ok"),
  756. })
  757. const runtime = CodeMode.make({
  758. tools: {
  759. github: { list_issues: simple("List issues"), create_issue: simple("Create an issue") },
  760. linear: { list_issues: simple("List Linear issues") },
  761. },
  762. })
  763. // Empty query + namespace browses just that namespace, alphabetical by path.
  764. const browse = await Effect.runPromise(runtime.execute(`return search({ query: "", namespace: "github" })`))
  765. expect(browse.ok).toBe(true)
  766. if (browse.ok) {
  767. const value = browse.value as { items: Array<{ path: string }>; remaining: number }
  768. expect(value.remaining).toBe(0)
  769. expect(value.items.map((item) => item.path)).toStrictEqual([
  770. "tools.github.create_issue",
  771. "tools.github.list_issues",
  772. ])
  773. }
  774. // A query + namespace ranks within that namespace only.
  775. const scoped = await Effect.runPromise(runtime.execute(`return search({ query: "issues", namespace: "linear" })`))
  776. expect(scoped.ok).toBe(true)
  777. if (scoped.ok) {
  778. const value = scoped.value as { items: Array<{ path: string }>; remaining: number }
  779. expect(value.remaining).toBe(0)
  780. expect(value.items[0]?.path).toBe("tools.linear.list_issues")
  781. }
  782. const invalid = await Effect.runPromise(runtime.execute(`return search({ query: "issues", namespace: 7 })`))
  783. expect(invalid.ok).toBe(false)
  784. if (!invalid.ok) expect(invalid.error.kind).toBe("InvalidToolInput")
  785. })
  786. test("matches input parameter names and partial-word substrings", async () => {
  787. const upload = Tool.make({
  788. description: "Send a document to the workspace",
  789. input: {
  790. type: "object",
  791. properties: { attachment: { type: "string", description: "Local path of the payload to send" } },
  792. required: ["attachment"],
  793. },
  794. execute: () => Effect.succeed("ok"),
  795. })
  796. const other = Tool.make({
  797. description: "Rename the workspace",
  798. input: Schema.Struct({ name: Schema.String }),
  799. output: Schema.String,
  800. execute: () => Effect.succeed("ok"),
  801. })
  802. const runtime = CodeMode.make({ tools: { files: { upload, other } } })
  803. // "attachment" appears in neither path nor description - only in the input schema's
  804. // property names, which the searchable text includes.
  805. const byParameter = await Effect.runPromise(runtime.execute(`return search({ query: "attachment" })`))
  806. expect(byParameter.ok).toBe(true)
  807. if (byParameter.ok) {
  808. const value = byParameter.value as { items: Array<{ path: string }>; remaining: number }
  809. expect(value.remaining).toBe(0)
  810. expect(value.items[0]?.path).toBe("tools.files.upload")
  811. }
  812. // Substring matching: a partial word ("docum") still hits the description.
  813. const bySubstring = await Effect.runPromise(runtime.execute(`return search({ query: "docum" })`))
  814. expect(bySubstring.ok).toBe(true)
  815. if (bySubstring.ok) {
  816. const value = bySubstring.value as { items: Array<{ path: string }>; remaining: number }
  817. expect(value.remaining).toBe(0)
  818. expect(value.items[0]?.path).toBe("tools.files.upload")
  819. }
  820. })
  821. test("a plural query term matches singular-only tool text", async () => {
  822. const simple = (description: string) =>
  823. Tool.make({
  824. description,
  825. input: Schema.Struct({ id: Schema.String }),
  826. output: Schema.String,
  827. execute: () => Effect.succeed("ok"),
  828. })
  829. const runtime = CodeMode.make({
  830. tools: {
  831. // Neither path nor description contains "issues" - only the singular "issue".
  832. tracker: { fetch_all: simple("Fetch every open issue in the project") },
  833. github: { list_issues: simple("List issues") },
  834. misc: { rename: simple("Rename the workspace") },
  835. },
  836. })
  837. // "issues" still finds the singular-only tool (term OR singular(term) per field)...
  838. const plural = await Effect.runPromise(runtime.execute(`return search({ query: "issues", namespace: "tracker" })`))
  839. expect(plural.ok).toBe(true)
  840. if (plural.ok) {
  841. const value = plural.value as { items: Array<{ path: string }>; remaining: number }
  842. expect(value.remaining).toBe(0)
  843. expect(value.items[0]?.path).toBe("tools.tracker.fetch_all")
  844. }
  845. // ...while a true "issues" path match still outranks the singular-only description match.
  846. const ranked = await Effect.runPromise(runtime.execute(`return search({ query: "issues" })`))
  847. expect(ranked.ok).toBe(true)
  848. if (ranked.ok) {
  849. const value = ranked.value as { items: Array<{ path: string }>; remaining: number }
  850. expect(value.remaining).toBe(0)
  851. expect(value.items.map((item) => item.path)).toStrictEqual([
  852. "tools.github.list_issues",
  853. "tools.tracker.fetch_all",
  854. ])
  855. }
  856. })
  857. test("empty query lists everything alphabetically by path", async () => {
  858. const simple = (description: string) =>
  859. Tool.make({
  860. description,
  861. input: Schema.Struct({}),
  862. output: Schema.String,
  863. execute: () => Effect.succeed("ok"),
  864. })
  865. // Deliberately declared out of alphabetical order.
  866. const runtime = CodeMode.make({
  867. tools: {
  868. zeta: { last: simple("Last") },
  869. alpha: { beta: simple("Middle"), aardvark: simple("First") },
  870. },
  871. })
  872. const browse = await Effect.runPromise(runtime.execute(`return search({})`))
  873. expect(browse.ok).toBe(true)
  874. if (browse.ok) {
  875. const value = browse.value as { items: Array<{ path: string }>; remaining: number; next: unknown }
  876. expect(value.items.map((item) => item.path)).toStrictEqual([
  877. "tools.alpha.aardvark",
  878. "tools.alpha.beta",
  879. "tools.zeta.last",
  880. ])
  881. expect(value.remaining).toBe(0)
  882. expect(value.next).toBeNull()
  883. }
  884. const middle = await Effect.runPromise(runtime.execute(`return search({ limit: 1, offset: 1 })`))
  885. expect(middle.ok).toBe(true)
  886. if (middle.ok) {
  887. expect(middle.value).toMatchObject({
  888. items: [{ path: "tools.alpha.beta" }],
  889. remaining: 1,
  890. next: { offset: 2 },
  891. })
  892. }
  893. const exhausted = await Effect.runPromise(runtime.execute(`return search({ limit: 1, offset: 3 })`))
  894. expect(exhausted.ok).toBe(true)
  895. if (exhausted.ok) expect(exhausted.value).toStrictEqual({ items: [], remaining: 0, next: null })
  896. })
  897. test("decodes tool input and output before exposing either side", async () => {
  898. const observed: Array<unknown> = []
  899. const transformed = Tool.make({
  900. description: "Double a number",
  901. input: Schema.Struct({ value: Schema.NumberFromString }),
  902. output: Schema.NumberFromString,
  903. execute: ({ value }) =>
  904. Effect.sync(() => {
  905. observed.push(value)
  906. return String(value * 2)
  907. }),
  908. })
  909. const runtime = CodeMode.make({
  910. tools: { math: { double: transformed } },
  911. onToolCallStart: (call) => Effect.sync(() => observed.push(call.input)),
  912. })
  913. const success = await Effect.runPromise(runtime.execute(`return await tools.math.double({ value: "21" })`))
  914. expect(success).toStrictEqual({ ok: true, value: 42, toolCalls: [{ name: "math.double" }] })
  915. expect(observed).toStrictEqual([{ value: 21 }, 21])
  916. const invalid = await Effect.runPromise(runtime.execute(`return await tools.math.double({ value: 21 })`))
  917. expect(invalid.ok).toBe(false)
  918. if (invalid.ok) return
  919. expect(invalid.error.kind).toBe("InvalidToolInput")
  920. expect(observed).toStrictEqual([{ value: 21 }, 21])
  921. })
  922. test("returns JSON-safe data and normalizes undefined to null", async () => {
  923. const result = await Effect.runPromise(
  924. CodeMode.execute({
  925. code: `return { top: undefined, nested: [1, undefined] }`,
  926. }),
  927. )
  928. expect(result).toStrictEqual({
  929. ok: true,
  930. value: { top: null, nested: [1, null] },
  931. toolCalls: [],
  932. })
  933. expect(Schema.decodeUnknownSync(CodeMode.Result)(JSON.parse(JSON.stringify(result)))).toStrictEqual(result)
  934. })
  935. test("returns the final top-level expression when return is omitted", async () => {
  936. const result = await Effect.runPromise(CodeMode.execute({ code: `1; 2` }))
  937. expect(result).toStrictEqual({ ok: true, value: 2, toolCalls: [] })
  938. })
  939. test("does not implicitly return expressions nested in control flow", async () => {
  940. const result = await Effect.runPromise(CodeMode.execute({ code: `if (true) { 2 }` }))
  941. expect(result).toStrictEqual({ ok: true, value: null, toolCalls: [] })
  942. })
  943. test("returns null when the final top-level statement is not an expression", async () => {
  944. const result = await Effect.runPromise(CodeMode.execute({ code: `1; const value = 2` }))
  945. expect(result).toStrictEqual({ ok: true, value: null, toolCalls: [] })
  946. })
  947. test("rejects invalid configuration and search limits", async () => {
  948. expect(() => CodeMode.execute({ code: "return 1", limits: { timeoutMs: 0 } })).toThrow(RangeError)
  949. expect(() => CodeMode.execute({ code: "return 1", limits: { timeoutMs: Number.POSITIVE_INFINITY } })).toThrow(
  950. RangeError,
  951. )
  952. expect(() => CodeMode.execute({ code: "return 1", limits: { maxToolCalls: -1 } })).toThrow(RangeError)
  953. expect(() => CodeMode.execute({ code: "return 1", limits: { maxOutputBytes: -1 } })).toThrow(RangeError)
  954. const result = await Effect.runPromise(
  955. CodeMode.make({ tools }).execute(`return search({ query: "order", limit: 0.5 })`),
  956. )
  957. expect(result.ok).toBe(false)
  958. if (result.ok) return
  959. expect(result.error.kind).toBe("InvalidToolInput")
  960. for (const offset of [-1, 0.5, Number.MAX_SAFE_INTEGER + 1, "1"]) {
  961. const invalidOffset = await Effect.runPromise(
  962. CodeMode.make({ tools }).execute(`return search({ query: "order", offset: ${JSON.stringify(offset)} })`),
  963. )
  964. expect(invalidOffset.ok).toBe(false)
  965. if (!invalidOffset.ok) expect(invalidOffset.error.kind).toBe("InvalidToolInput")
  966. }
  967. })
  968. test("enforces the tool-call limit as a diagnostic", async () => {
  969. const result = await Effect.runPromise(CodeMode.execute({ tools, code: source, limits: { maxToolCalls: 0 } }))
  970. expect(result.ok).toBe(false)
  971. if (!result.ok) expect(result.error.kind).toBe("ToolCallLimitExceeded")
  972. })
  973. test("timeoutMs and maxToolCalls have no defaults: absent means unlimited", async () => {
  974. // 150 tool calls would have exceeded the old default cap of 100; with no limits
  975. // provided, there is no cap and no timeout - budgets are host policy.
  976. const counter = Tool.make({
  977. description: "Count invocations",
  978. input: Schema.Struct({}),
  979. output: Schema.Number,
  980. execute: () => Effect.succeed(1),
  981. })
  982. const result = await Effect.runPromise(
  983. CodeMode.execute({
  984. tools: { host: { count: counter } },
  985. code: `
  986. let total = 0
  987. for (let i = 0; i < 150; i += 1) total += await tools.host.count({})
  988. return total
  989. `,
  990. }),
  991. )
  992. expect(result).toMatchObject({ ok: true, value: 150 })
  993. if (result.ok) expect(result.toolCalls.length).toBe(150)
  994. })
  995. test("the timeout interrupts a busy loop without any operation budget", async () => {
  996. // Regression: timeout interruption must not depend on interpreter-side work accounting.
  997. // The Effect fiber runtime auto-yields between interpreter steps, so a pure `while
  998. // (true) {}` loop is interrupted by `timeoutMs` alone.
  999. const startedAt = Date.now()
  1000. const result = await Effect.runPromise(CodeMode.execute({ code: "while (true) {}", limits: { timeoutMs: 200 } }))
  1001. const elapsedMs = Date.now() - startedAt
  1002. expect(result.ok).toBe(false)
  1003. if (!result.ok) {
  1004. expect(result.error.kind).toBe("TimeoutExceeded")
  1005. expect(result.error.message).toContain("timed out after 200ms")
  1006. }
  1007. expect(elapsedMs).toBeLessThan(3_000)
  1008. })
  1009. })