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
8 changed files with 82 additions and 66 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ import { Info, SchemaURL } from "../src/config/schema"
const target = process.argv[2]
if (!target) throw new Error("A schema output path is required")
const document = Schema.toJsonSchemaDocument(Info, { onExcessProperty: "error" })
const document = Schema.toJsonSchemaDocument(Info)
const content = await format(
JSON.stringify({
$schema: "https://json-schema.org/draft/2020-12/schema",
+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",
+5 -15
View File
@@ -130,8 +130,7 @@ export function normalize(input: unknown): Result {
const legacyAgents = mapValues(
decodeMap(input.agent, ConfigAgentV1.Info, ["agent"], diagnostics, decodeValue),
(value, name) =>
canonical(ConfigAgent.Info, ConfigMigrateV1.migrateAgent(value, legacyPermission(input.agent, name))),
(value) => canonical(ConfigAgent.Info, ConfigMigrateV1.migrateAgent(value)),
)
const legacySmallModel = own(input, "small_model")
? decodeValue(Schema.String, input.small_model, ["small_model"], diagnostics)
@@ -150,13 +149,8 @@ export function normalize(input: unknown): Result {
model: migratedSmallModel,
...legacyAgents.title,
}
const modeAgents = mapValues(
decodeMap(input.mode, ConfigAgentV1.Info, ["mode"], diagnostics, decodeValue),
(value, name) =>
canonical(
ConfigAgent.Info,
ConfigMigrateV1.migrateAgent({ ...value, mode: "primary" }, legacyPermission(input.mode, name)),
),
const modeAgents = mapValues(decodeMap(input.mode, ConfigAgentV1.Info, ["mode"], diagnostics, decodeValue), (value) =>
canonical(ConfigAgent.Info, ConfigMigrateV1.migrateAgent({ ...value, mode: "primary" })),
)
const migratedAgents = mergeMaps(legacyAgents, modeAgents, ["agents"], diagnostics)
const nativeAgents = decodeMap(input.agents, ConfigAgent.Info, ["agents"], diagnostics, decodeEncoded)
@@ -748,10 +742,10 @@ function mergeMaps(
return result
}
function mapValues<A>(input: Readonly<Record<string, A>>, map: (value: A, key: string) => unknown) {
function mapValues<A>(input: Readonly<Record<string, A>>, map: (value: A) => unknown) {
return Object.fromEntries(
Object.entries(input).flatMap(([key, value]) => {
const mapped = map(value, key)
const mapped = map(value)
return mapped === undefined ? [] : [[key, mapped]]
}),
)
@@ -801,10 +795,6 @@ function isPlainRecord(value: unknown): value is Record<string, unknown> {
return prototype === Object.prototype || prototype === null
}
function legacyPermission(agents: unknown, name: string) {
return isRecord(agents) && isRecord(agents[name]) ? agents[name].permission : undefined
}
function own(value: Record<string, unknown>, key: string) {
return Object.hasOwn(value, key)
}
+3 -2
View File
@@ -195,8 +195,9 @@ function decode(file: { directory: string; filepath: string; primary: boolean },
: markdown.data
const agent = legacy
? Option.getOrUndefined(
Option.map(decodeLegacyAgent({ name, ...markdown.data, prompt: body }, { errors: "all" }), (agent) =>
ConfigMigrateV1.migrateAgent(agent, markdown.data.permission),
Option.map(
decodeLegacyAgent({ name, ...markdown.data, prompt: body }, { errors: "all" }),
ConfigMigrateV1.migrateAgent,
),
)
: Option.getOrUndefined(decodeAgent({ ...data, system: body }, { errors: "all" }))
+2 -8
View File
@@ -113,12 +113,6 @@ function permissions(info?: ConfigPermissionV1.Info, tools?: Readonly<Record<str
return rules.length ? rules : undefined
}
// Effect emits declared keys in schema order. Permission rules are last-match-wins, so restore the author's key order.
function inInputOrder(info: ConfigPermissionV1.Info | undefined, input: unknown): ConfigPermissionV1.Info | undefined {
if (info === undefined || typeof input !== "object" || input === null) return info
return Object.fromEntries(Object.keys(input).flatMap((key) => (Object.hasOwn(info, key) ? [[key, info[key]]] : [])))
}
// Map v1 permission/tool keys onto their renamed v2 tool actions so migrated rules keep matching.
export function normalizeAction(action: string) {
if (action === "write" || action === "patch") return "edit"
@@ -144,7 +138,7 @@ function agents(info: typeof ConfigV1.Info.Type) {
}
}
export function migrateAgent(info: ConfigAgentV1.Info, permission?: unknown) {
export function migrateAgent(info: ConfigAgentV1.Info) {
const body = {
...info.options,
...(info.temperature === undefined ? {} : { temperature: info.temperature }),
@@ -162,7 +156,7 @@ export function migrateAgent(info: ConfigAgentV1.Info, permission?: unknown) {
color: info.color === undefined ? undefined : info.color.startsWith("#") ? info.color : "#aaaaaa",
steps: info.steps,
disabled: info.disable,
permissions: permissions(inInputOrder(info.permission, permission)),
permissions: permissions(info.permission),
}),
),
)
-9
View File
@@ -119,15 +119,6 @@ permissions:
}
}
it.live("keeps Markdown legacy permission precedence in source order", () =>
Effect.gen(function* () {
const agent = yield* loadMarkdownAgent('permission:\n "*": allow\n bash: ask\n edit: deny')
expect(Permission.evaluate("shell", "ls", agent.permissions).effect).toBe("ask")
expect(Permission.evaluate("edit", "example.txt", agent.permissions).effect).toBe("deny")
expect(Permission.evaluate("webfetch", "*", agent.permissions).effect).toBe("allow")
}),
)
for (const variant of [undefined, "high"]) {
it.live(`loads Markdown legacy temperature ${variant ? "with" : "without"} a separate variant`, () =>
Effect.gen(function* () {
@@ -240,30 +240,6 @@ describe("ConfigNormalize", () => {
])
})
test.each(["agent", "mode"])("preserves legacy %s permission source order", (key) => {
const permission = { "*": "allow", bash: "ask", custom: "deny", edit: "deny" }
expect(normalized({ [key]: { build: { permission } } }).encoded.agents).toMatchObject({
build: {
permissions: [
{ action: "*", resource: "*", effect: "allow" },
{ action: "shell", resource: "*", effect: "ask" },
{ action: "custom", resource: "*", effect: "deny" },
{ action: "edit", resource: "*", effect: "deny" },
],
},
})
expect(
normalized({ [key]: { review: { permission: { "*": "deny", read: "allow" } } } }).encoded.agents,
).toMatchObject({
review: {
permissions: [
{ action: "*", resource: "*", effect: "deny" },
{ action: "read", resource: "*", effect: "allow" },
],
},
})
})
test("redacts permission resource keys from invalid diagnostics", () => {
const result = normalized({
permission: { bash: { "curl -H Authorization:Bearer TOPSECRET *": "bogus" } },