codemode.test.ts 41 KB

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