Compare commits

...
Author SHA1 Message Date
Kit Langton 29dc70cff4 fix(codemode): derive empty tool inputs from the encoded schema 2026-09-27 10:05:39 -03:00
2 changed files with 71 additions and 7 deletions
+3 -6
View File
@@ -264,15 +264,12 @@ export const inputProperties = <R>(tool: Tool<R>): Array<InputProperty> => {
export const inputTypeScript = <R>(tool: Tool<R>, pretty = false): string =>
isEffectSchema(tool.input) ? toTypeScript(tool.input, false, pretty) : jsonSchemaToTypeScript(tool.input, pretty)
// Effect 4.0 models an empty Struct as a non-nullish object, so inspect its AST.
// Effect 4.0 models an empty Struct as a non-nullish object, so inspect the encoded AST callers supply.
// Raw JSON Schema inputs retain the compact `{}` rendering check.
export const isEmptyInput = <R>(tool: Tool<R>): boolean => {
if (!isEffectSchema(tool.input)) return inputTypeScript(tool) === "{}"
return (
SchemaAST.isObjects(tool.input.ast) &&
tool.input.ast.propertySignatures.length === 0 &&
tool.input.ast.indexSignatures.length === 0
)
const encoded = SchemaAST.toEncoded(tool.input.ast)
return SchemaAST.isObjects(encoded) && encoded.propertySignatures.length === 0 && encoded.indexSignatures.length === 0
}
export const outputTypeScript = <R>(tool: Tool<R>, pretty = false): string =>
+68 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { Effect, Schema } from "effect"
import { Effect, Schema, SchemaGetter } from "effect"
import { CodeMode, Tool } from "../src/index.js"
import {
decodeInput,
@@ -878,6 +878,73 @@ describe("JSDoc signatures in catalogs and search results", () => {
})
})
describe("empty input signatures agree with decoding", () => {
test.each([
{ name: "a plain empty struct", input: Schema.Struct({}) },
{
name: "an annotated empty struct",
input: Schema.Struct({}).annotate({ identifier: "Empty", description: "No input" }),
},
{ name: "a checked empty struct", input: Schema.Struct({}).check(Schema.makeFilter(() => true)) },
{
name: "an empty encoded struct transformed to a non-empty struct",
input: Schema.Struct({}).pipe(
Schema.decodeTo(Schema.Struct({ token: Schema.String }), {
decode: SchemaGetter.transform(() => ({ token: "x" })),
encode: SchemaGetter.transform(() => ({})),
}),
),
},
{ name: "a raw JSON Schema empty object", input: { type: "object", properties: {} } },
])("$name advertises () and runs with zero arguments", async ({ input }) => {
const runtime = CodeMode.make({
tools: {
ping: Tool.make({ description: "Ping", input, output: Schema.String, execute: () => Effect.succeed("pong") }),
},
})
const signature = "tools.ping(): Promise<string>"
expect(runtime.catalog[0]?.signature).toBe(signature)
const result = await Effect.runPromise(runtime.execute('return search({ query: "tools.ping" })'))
expect(result.ok).toBe(true)
if (!result.ok) throw new Error("search failed")
expect(result.value).toMatchObject({ items: [{ signature }] })
expect(await Effect.runPromise(runtime.execute("return await tools.ping()"))).toMatchObject({
ok: true,
value: "pong",
})
})
test("a required encoded input transformed to an empty struct keeps its required fields", async () => {
const consume = Tool.make({
description: "Consume token",
input: Schema.Struct({ token: Schema.String }).pipe(
Schema.decodeTo(Schema.Struct({}), {
decode: SchemaGetter.transform(() => ({})),
encode: SchemaGetter.transform(() => ({ token: "x" })),
}),
),
output: Schema.String,
execute: () => Effect.succeed("consumed"),
})
const runtime = CodeMode.make({ tools: { consume } })
const signature = "tools.consume({\n token: string,\n}): Promise<string>"
expect(() => decodeInput(consume, {})).toThrow()
expect(runtime.catalog[0]?.signature).toBe(signature)
const result = await Effect.runPromise(runtime.execute('return search({ query: "tools.consume" })'))
expect(result.ok).toBe(true)
if (!result.ok) throw new Error("search failed")
expect(result.value).toMatchObject({ items: [{ signature }] })
expect(await Effect.runPromise(runtime.execute("return await tools.consume()"))).toMatchObject({
ok: false,
error: { kind: "InvalidToolInput" },
})
expect(await Effect.runPromise(runtime.execute('return await tools.consume({ token: "t" })'))).toMatchObject({
ok: true,
value: "consumed",
})
})
})
describe("non-identifier tool paths", () => {
const resolveLibrary = Tool.make({
description: "Resolve a Context7 library ID",