codemode.test.ts 42 KB

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