tool-define.test.ts 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154
  1. import { describe, expect } from "bun:test"
  2. import { LayerNode } from "@kirincode-ai/core/effect/layer-node"
  3. import { Cause, Effect, Exit, Schema } from "effect"
  4. import { Agent } from "../../src/agent/agent"
  5. import { MessageID, SessionID } from "../../src/session/schema"
  6. import { Tool } from "@/tool/tool"
  7. import { Truncate } from "@/tool/truncate"
  8. import { testEffect } from "../lib/effect"
  9. const it = testEffect(LayerNode.compile(LayerNode.group([Truncate.node, Agent.node])))
  10. const params = Schema.Struct({ input: Schema.String })
  11. function makeCtx(): Tool.Context {
  12. return {
  13. sessionID: SessionID.descending(),
  14. messageID: MessageID.ascending(),
  15. agent: "build",
  16. abort: new AbortController().signal,
  17. messages: [],
  18. metadata() {
  19. return Effect.void
  20. },
  21. ask() {
  22. return Effect.void
  23. },
  24. }
  25. }
  26. function makeTool(id: string, executeFn?: () => void) {
  27. return {
  28. description: "test tool",
  29. parameters: params,
  30. execute() {
  31. executeFn?.()
  32. return Effect.succeed({ title: "test", output: "ok", metadata: {} })
  33. },
  34. }
  35. }
  36. describe("Tool.define", () => {
  37. it.effect("object-defined tool does not mutate the original init object", () =>
  38. Effect.gen(function* () {
  39. const original = makeTool("test")
  40. const originalExecute = original.execute
  41. const info = yield* Tool.define("test-tool", Effect.succeed(original))
  42. yield* info.init()
  43. yield* info.init()
  44. yield* info.init()
  45. expect(original.execute).toBe(originalExecute)
  46. }),
  47. )
  48. it.effect("effect-defined tool returns fresh objects and is unaffected", () =>
  49. Effect.gen(function* () {
  50. const info = yield* Tool.define(
  51. "test-fn-tool",
  52. Effect.succeed(() => Effect.succeed(makeTool("test"))),
  53. )
  54. const first = yield* info.init()
  55. const second = yield* info.init()
  56. expect(first).not.toBe(second)
  57. }),
  58. )
  59. it.effect("object-defined tool returns distinct objects per init() call", () =>
  60. Effect.gen(function* () {
  61. const info = yield* Tool.define("test-copy", Effect.succeed(makeTool("test")))
  62. const first = yield* info.init()
  63. const second = yield* info.init()
  64. expect(first).not.toBe(second)
  65. }),
  66. )
  67. it.effect("execute receives decoded parameters", () =>
  68. Effect.gen(function* () {
  69. const parameters = Schema.Struct({
  70. count: Schema.NumberFromString.pipe(Schema.optional, Schema.withDecodingDefaultType(Effect.succeed(5))),
  71. })
  72. const calls: Array<Schema.Schema.Type<typeof parameters>> = []
  73. const info = yield* Tool.define(
  74. "test-decoded",
  75. Effect.succeed({
  76. description: "test tool",
  77. parameters,
  78. execute(args: Schema.Schema.Type<typeof parameters>) {
  79. calls.push(args)
  80. return Effect.succeed({ title: "test", output: "ok", metadata: { truncated: false } })
  81. },
  82. }),
  83. )
  84. const ctx = makeCtx()
  85. const tool = yield* info.init()
  86. const execute = tool.execute as unknown as (args: unknown, ctx: Tool.Context) => ReturnType<typeof tool.execute>
  87. yield* execute({}, ctx)
  88. yield* execute({ count: "7" }, ctx)
  89. expect(calls).toEqual([{ count: 5 }, { count: 7 }])
  90. }),
  91. )
  92. // Regression for #28438: the wrap is the canonical "untyped → typed" boundary.
  93. // When the LLM emits a tool call with a payload that fails the parameter
  94. // schema, the wrap must surface a typed `Tool.InvalidArgumentsError` whose
  95. // `.message` is the actionable prose the AI SDK feeds back to the model.
  96. it.effect("invalid args surface as Tool.InvalidArgumentsError with friendly message and JSON path", () =>
  97. Effect.gen(function* () {
  98. const parameters = Schema.Struct({
  99. questions: Schema.Array(
  100. Schema.Struct({
  101. question: Schema.String,
  102. options: Schema.Array(Schema.String),
  103. }),
  104. ),
  105. })
  106. const info = yield* Tool.define(
  107. "qtest",
  108. Effect.succeed({
  109. description: "test tool",
  110. parameters,
  111. execute() {
  112. return Effect.succeed({ title: "ok", output: "ok", metadata: { truncated: false } })
  113. },
  114. }),
  115. )
  116. const tool = yield* info.init()
  117. const execute = tool.execute as unknown as (args: unknown, ctx: Tool.Context) => ReturnType<typeof tool.execute>
  118. // Missing required `question` field on the first questions[] entry.
  119. const exit = yield* execute({ questions: [{ options: ["a"] }] }, makeCtx()).pipe(Effect.exit)
  120. expect(Exit.isFailure(exit)).toBe(true)
  121. if (!Exit.isFailure(exit)) return
  122. // The wrap ends with Effect.orDie, so the failure lives in the cause as a
  123. // defect. Recover the typed instance from there.
  124. const die = exit.cause.reasons.find(Cause.isDieReason)
  125. const error = die?.defect
  126. expect(error).toBeInstanceOf(Tool.InvalidArgumentsError)
  127. const args = error as Tool.InvalidArgumentsError
  128. expect(args.tool).toBe("qtest")
  129. expect(args.message).toContain("qtest tool was called with invalid arguments")
  130. expect(args.message).toContain("Please rewrite the input")
  131. expect(args.message).toContain(`["questions"][0]["question"]`)
  132. }),
  133. )
  134. })