| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154 |
- import { describe, expect } from "bun:test"
- import { LayerNode } from "@kirincode-ai/core/effect/layer-node"
- import { Cause, Effect, Exit, Schema } from "effect"
- import { Agent } from "../../src/agent/agent"
- import { MessageID, SessionID } from "../../src/session/schema"
- import { Tool } from "@/tool/tool"
- import { Truncate } from "@/tool/truncate"
- import { testEffect } from "../lib/effect"
- const it = testEffect(LayerNode.compile(LayerNode.group([Truncate.node, Agent.node])))
- const params = Schema.Struct({ input: Schema.String })
- function makeCtx(): Tool.Context {
- return {
- sessionID: SessionID.descending(),
- messageID: MessageID.ascending(),
- agent: "build",
- abort: new AbortController().signal,
- messages: [],
- metadata() {
- return Effect.void
- },
- ask() {
- return Effect.void
- },
- }
- }
- function makeTool(id: string, executeFn?: () => void) {
- return {
- description: "test tool",
- parameters: params,
- execute() {
- executeFn?.()
- return Effect.succeed({ title: "test", output: "ok", metadata: {} })
- },
- }
- }
- describe("Tool.define", () => {
- it.effect("object-defined tool does not mutate the original init object", () =>
- Effect.gen(function* () {
- const original = makeTool("test")
- const originalExecute = original.execute
- const info = yield* Tool.define("test-tool", Effect.succeed(original))
- yield* info.init()
- yield* info.init()
- yield* info.init()
- expect(original.execute).toBe(originalExecute)
- }),
- )
- it.effect("effect-defined tool returns fresh objects and is unaffected", () =>
- Effect.gen(function* () {
- const info = yield* Tool.define(
- "test-fn-tool",
- Effect.succeed(() => Effect.succeed(makeTool("test"))),
- )
- const first = yield* info.init()
- const second = yield* info.init()
- expect(first).not.toBe(second)
- }),
- )
- it.effect("object-defined tool returns distinct objects per init() call", () =>
- Effect.gen(function* () {
- const info = yield* Tool.define("test-copy", Effect.succeed(makeTool("test")))
- const first = yield* info.init()
- const second = yield* info.init()
- expect(first).not.toBe(second)
- }),
- )
- it.effect("execute receives decoded parameters", () =>
- Effect.gen(function* () {
- const parameters = Schema.Struct({
- count: Schema.NumberFromString.pipe(Schema.optional, Schema.withDecodingDefaultType(Effect.succeed(5))),
- })
- const calls: Array<Schema.Schema.Type<typeof parameters>> = []
- const info = yield* Tool.define(
- "test-decoded",
- Effect.succeed({
- description: "test tool",
- parameters,
- execute(args: Schema.Schema.Type<typeof parameters>) {
- calls.push(args)
- return Effect.succeed({ title: "test", output: "ok", metadata: { truncated: false } })
- },
- }),
- )
- const ctx = makeCtx()
- const tool = yield* info.init()
- const execute = tool.execute as unknown as (args: unknown, ctx: Tool.Context) => ReturnType<typeof tool.execute>
- yield* execute({}, ctx)
- yield* execute({ count: "7" }, ctx)
- expect(calls).toEqual([{ count: 5 }, { count: 7 }])
- }),
- )
- // Regression for #28438: the wrap is the canonical "untyped → typed" boundary.
- // When the LLM emits a tool call with a payload that fails the parameter
- // schema, the wrap must surface a typed `Tool.InvalidArgumentsError` whose
- // `.message` is the actionable prose the AI SDK feeds back to the model.
- it.effect("invalid args surface as Tool.InvalidArgumentsError with friendly message and JSON path", () =>
- Effect.gen(function* () {
- const parameters = Schema.Struct({
- questions: Schema.Array(
- Schema.Struct({
- question: Schema.String,
- options: Schema.Array(Schema.String),
- }),
- ),
- })
- const info = yield* Tool.define(
- "qtest",
- Effect.succeed({
- description: "test tool",
- parameters,
- execute() {
- return Effect.succeed({ title: "ok", output: "ok", metadata: { truncated: false } })
- },
- }),
- )
- const tool = yield* info.init()
- const execute = tool.execute as unknown as (args: unknown, ctx: Tool.Context) => ReturnType<typeof tool.execute>
- // Missing required `question` field on the first questions[] entry.
- const exit = yield* execute({ questions: [{ options: ["a"] }] }, makeCtx()).pipe(Effect.exit)
- expect(Exit.isFailure(exit)).toBe(true)
- if (!Exit.isFailure(exit)) return
- // The wrap ends with Effect.orDie, so the failure lives in the cause as a
- // defect. Recover the typed instance from there.
- const die = exit.cause.reasons.find(Cause.isDieReason)
- const error = die?.defect
- expect(error).toBeInstanceOf(Tool.InvalidArgumentsError)
- const args = error as Tool.InvalidArgumentsError
- expect(args.tool).toBe("qtest")
- expect(args.message).toContain("qtest tool was called with invalid arguments")
- expect(args.message).toContain("Please rewrite the input")
- expect(args.message).toContain(`["questions"][0]["question"]`)
- }),
- )
- })
|