diff --git a/packages/workflow-utils/src/__tests__/llm-extract.test.ts b/packages/workflow-utils/src/__tests__/llm-extract.test.ts index 5d7464e..b96af35 100644 --- a/packages/workflow-utils/src/__tests__/llm-extract.test.ts +++ b/packages/workflow-utils/src/__tests__/llm-extract.test.ts @@ -108,7 +108,7 @@ describe("llmExtract", () => { expect(result.error.kind).toBe("schema_validation_failed"); }); - it("dryRun skips fetch and returns an empty stub value", async () => { + it("dryRun skips fetch and returns schema-shaped stub values", async () => { const fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); @@ -125,6 +125,6 @@ describe("llmExtract", () => { if (!result.ok) { return; } - expect(result.value).toEqual({}); + expect(result.value).toEqual({ n: 0 }); }); }); diff --git a/packages/workflow-utils/src/__tests__/schema-defaults.test.ts b/packages/workflow-utils/src/__tests__/schema-defaults.test.ts new file mode 100644 index 0000000..a045571 --- /dev/null +++ b/packages/workflow-utils/src/__tests__/schema-defaults.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { schemaDefaults } from "../schema-defaults.js"; + +describe("schemaDefaults", () => { + it("fills nested objects with primitive placeholders", () => { + const schema = z.object({ + meta: z.object({ + id: z.string(), + count: z.number(), + flag: z.boolean(), + }), + }); + expect(schemaDefaults(schema)).toEqual({ + meta: { id: "", count: 0, flag: false }, + }); + }); + + it("uses empty arrays for array fields", () => { + const schema = z.object({ + roles: z.array(z.object({ name: z.string(), level: z.number() })), + }); + const out = schemaDefaults(schema) as { roles: { name: string; level: number }[] }; + expect(out.roles).toEqual([]); + expect(out.roles.map((r) => r.name)).toEqual([]); + }); + + it("uses the first enum value", () => { + const schema = z.object({ + status: z.enum(["pending", "done", "failed"]), + code: z.nativeEnum({ A: 1, B: 2 }), + }); + expect(schemaDefaults(schema)).toEqual({ + status: "pending", + code: 1, + }); + }); + + it("sets optional fields to undefined and omits exactOptional keys", () => { + const schema = z.object({ + req: z.string(), + maybe: z.string().optional(), + exact: z.string().exactOptional(), + }); + expect(schemaDefaults(schema)).toEqual({ + req: "", + maybe: undefined, + }); + expect(Object.keys(schemaDefaults(schema) as object).includes("exact")).toBe(false); + }); + + it("respects .default()", () => { + const schema = z.object({ + n: z.number().default(42), + }); + expect(schemaDefaults(schema)).toEqual({ n: 42 }); + }); +}); diff --git a/packages/workflow-utils/src/index.ts b/packages/workflow-utils/src/index.ts index 513c627..1cbf361 100644 --- a/packages/workflow-utils/src/index.ts +++ b/packages/workflow-utils/src/index.ts @@ -11,6 +11,7 @@ export { type LlmExtractOptions, type LlmProvider, } from "./llm-extract.js"; +export { schemaDefaults } from "./schema-defaults.js"; export { nerveCommandEnv, spawnSafe, diff --git a/packages/workflow-utils/src/llm-extract.ts b/packages/workflow-utils/src/llm-extract.ts index ab78e66..9be735f 100644 --- a/packages/workflow-utils/src/llm-extract.ts +++ b/packages/workflow-utils/src/llm-extract.ts @@ -1,6 +1,8 @@ import { type Result, err, ok } from "@uncaged/nerve-core"; import { toJSONSchema, type z } from "zod"; +import { schemaDefaults } from "./schema-defaults.js"; + export type LlmProvider = { baseUrl: string; apiKey: string; @@ -102,7 +104,7 @@ export async function llmExtract( ): Promise> { const dryRun = resolveLlmExtractDryRun(options); if (dryRun) { - return ok({} as T); + return ok(schemaDefaults(options.schema) as T); } const rawJsonSchema = toJSONSchema(options.schema) as Record; diff --git a/packages/workflow-utils/src/schema-defaults.ts b/packages/workflow-utils/src/schema-defaults.ts new file mode 100644 index 0000000..384b3ea --- /dev/null +++ b/packages/workflow-utils/src/schema-defaults.ts @@ -0,0 +1,190 @@ +import type { z } from "zod"; + +type ZodTypeAny = z.ZodType; + +type Def = Record & { type: string }; +type TypeHandler = (schema: ZodTypeAny, def: Def) => unknown; + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isZodExactOptional(s: ZodTypeAny): boolean { + return s.constructor.name === "ZodExactOptional"; +} + +function resolveDefaultValue(defaultValue: unknown | (() => unknown)): unknown { + if (typeof defaultValue === "function") { + return (defaultValue as () => unknown)(); + } + return defaultValue; +} + +function mergeIntersection(left: unknown, right: unknown): unknown { + if (isPlainObject(left) && isPlainObject(right)) { + return { ...left, ...right }; + } + return right; +} + +function defaultsForObject(_schema: ZodTypeAny, def: Def): unknown { + const shape = def.shape as Record | undefined; + if (shape === undefined) { + return {}; + } + const out: Record = {}; + for (const key of Object.keys(shape)) { + const child = shape[key]; + const cdef = child.def as { type: string }; + if (cdef.type === "optional") { + if (isZodExactOptional(child)) { + continue; + } + out[key] = undefined; + } else { + out[key] = schemaDefaultsInner(child); + } + } + return out; +} + +function firstUnionOption(_schema: ZodTypeAny, def: Def): unknown { + const options = def.options as readonly ZodTypeAny[] | undefined; + if (options === undefined || options.length === 0) { + return null; + } + return schemaDefaultsInner(options[0]); +} + +function defaultsFromNullable(_schema: ZodTypeAny, _def: Def): unknown { + return null; +} + +function defaultsFromInner(_schema: ZodTypeAny, def: Def): unknown { + const inner = def.innerType as ZodTypeAny | undefined; + if (inner === undefined) { + return null; + } + return schemaDefaultsInner(inner); +} + +function defaultsForPipe(_schema: ZodTypeAny, def: Def): unknown { + const out = def.out as ZodTypeAny | undefined; + if (out === undefined) { + return null; + } + return schemaDefaultsInner(out); +} + +function defaultsForIntersection(_schema: ZodTypeAny, def: Def): unknown { + const left = def.left as ZodTypeAny | undefined; + const right = def.right as ZodTypeAny | undefined; + if (left === undefined || right === undefined) { + return null; + } + return mergeIntersection(schemaDefaultsInner(left), schemaDefaultsInner(right)); +} + +function defaultsForTuple(_schema: ZodTypeAny, def: Def): unknown { + const items = def.items as readonly ZodTypeAny[] | undefined; + if (items === undefined) { + return []; + } + return items.map((item) => schemaDefaultsInner(item)); +} + +function defaultsForLazy(schema: ZodTypeAny, def: Def): unknown { + const inner = + (schema as { _zod?: { innerType?: ZodTypeAny } })._zod?.innerType ?? + (def.getter as (() => ZodTypeAny) | undefined)?.(); + if (inner === undefined) { + return null; + } + return schemaDefaultsInner(inner); +} + +function defaultsForPromise(_schema: ZodTypeAny, def: Def): unknown { + const inner = def.innerType as ZodTypeAny | undefined; + if (inner === undefined) { + return Promise.resolve(null); + } + return Promise.resolve(schemaDefaultsInner(inner)); +} + +function firstEnumValue(_schema: ZodTypeAny, def: Def): unknown { + const entries = def.entries as Record | undefined; + if (entries === undefined) { + return null; + } + const values = Object.values(entries); + return values[0] ?? null; +} + +function firstLiteralValue(_schema: ZodTypeAny, def: Def): unknown { + const values = def.values as unknown[] | undefined; + if (values === undefined || values.length === 0) { + return null; + } + return values[0]; +} + +const TYPE_HANDLERS: Record = { + string: () => "", + number: () => 0, + boolean: () => false, + bigint: () => 0n, + date: () => new Date(0), + symbol: () => Symbol(), + undefined: () => undefined, + null: () => null, + void: () => undefined, + any: () => null, + unknown: () => null, + never: () => undefined, + nan: () => Number.NaN, + array: () => [], + object: defaultsForObject, + record: () => ({}), + map: () => new Map(), + set: () => new Set(), + enum: firstEnumValue, + literal: firstLiteralValue, + optional: () => undefined, + nullable: defaultsFromNullable, + default: (_s, def) => resolveDefaultValue(def.defaultValue as unknown | (() => unknown)), + prefault: (_s, def) => resolveDefaultValue(def.defaultValue as unknown | (() => unknown)), + nonoptional: defaultsFromInner, + catch: defaultsFromInner, + success: () => false, + readonly: defaultsFromInner, + union: firstUnionOption, + xor: firstUnionOption, + intersection: defaultsForIntersection, + pipe: defaultsForPipe, + transform: () => null, + tuple: defaultsForTuple, + lazy: defaultsForLazy, + promise: defaultsForPromise, + file: () => new File([], ""), + function: () => null, + custom: () => null, + template_literal: () => "", +}; + +/** + * Produces a structurally valid placeholder that mirrors primitive/array/object + * shape for a Zod schema. Used for `llmExtract` dry runs so downstream code + * (e.g. `.roles.map`) does not throw on `undefined` fields. + */ +export function schemaDefaults(schema: z.ZodType): unknown { + return schemaDefaultsInner(schema as ZodTypeAny); +} + +function schemaDefaultsInner(schema: ZodTypeAny): unknown { + const def = schema.def as Def; + const run = TYPE_HANDLERS[def.type]; + if (run === undefined) { + return null; + } + return run(schema, def); +}