diff --git a/typescript2/app-vscode-webview/src/ExecutionPanel.strict-mode.test.tsx b/typescript2/app-vscode-webview/src/ExecutionPanel.strict-mode.test.tsx index 4451f20f9cd..08bd6cf5260 100644 --- a/typescript2/app-vscode-webview/src/ExecutionPanel.strict-mode.test.tsx +++ b/typescript2/app-vscode-webview/src/ExecutionPanel.strict-mode.test.tsx @@ -328,6 +328,199 @@ describe('ExecutionPanel args form', () => { }); }); + it('reconciles cached form values when a same-name function schema changes', async () => { + const port = new FakeRuntimePort(); + + render(); + + act(() => { + port.emit({ + type: 'playgroundNotification', + notification: { type: 'listProjects', projects: ['project'] }, + }); + port.emit({ + type: 'playgroundNotification', + notification: { + type: 'updateProject', + project: 'project', + update: { + isBexCurrent: true, + functions: [ + { + name: 'EchoPerson', + kind: 'expr', + origin: 'userDefined', + params: [ + { + name: 'person', + hasDefault: false, + schema: { type: 'ref', name: 'user.Person' }, + }, + ], + }, + ], + types: { + 'user.Person': { + kind: 'class', + fields: [ + { name: 'name', schema: { type: 'string' } }, + { name: 'active', schema: { type: 'bool' } }, + ], + }, + }, + diagnostics: [], + }, + }, + }); + }); + + fireEvent.click( + await screen.findByRole('button', { name: 'Functions (1)' }), + ); + fireEvent.click(await screen.findByRole('button', { name: 'EchoPerson' })); + fireEvent.change(await screen.findByPlaceholderText('text'), { + target: { value: 'Ada' }, + }); + + // Hot-reload the same function name with a new required class field. + act(() => { + port.emit({ + type: 'playgroundNotification', + notification: { + type: 'updateProject', + project: 'project', + update: { + isBexCurrent: true, + functions: [ + { + name: 'EchoPerson', + kind: 'expr', + origin: 'userDefined', + params: [ + { + name: 'person', + hasDefault: false, + schema: { type: 'ref', name: 'user.Person' }, + }, + ], + }, + ], + types: { + 'user.Person': { + kind: 'class', + fields: [ + { name: 'name', schema: { type: 'string' } }, + { name: 'active', schema: { type: 'bool' } }, + { name: 'age', schema: { type: 'int' } }, + ], + }, + }, + diagnostics: [], + }, + }, + }); + }); + + const ageInput = await screen.findByPlaceholderText('0'); + await waitFor(() => { + expect(ageInput).toHaveValue('0'); + expect(screen.getByPlaceholderText('text')).toHaveValue('Ada'); + }); + fireEvent.change(screen.getByPlaceholderText('text'), { + target: { value: 'Ada Lovelace' }, + }); + + fireEvent.click(await screen.findByRole('button', { name: 'raw' })); + const rawInput = await screen.findByPlaceholderText('{"key": "value"}'); + await waitFor(() => { + expect(JSON.parse((rawInput as HTMLInputElement).value)).toEqual({ + person: { + $baml: { type: 'user.Person' }, + name: 'Ada Lovelace', + active: false, + age: 0, + }, + }); + }); + }); + + it('serializes untouched defaults inside a required nested class', async () => { + const port = new FakeRuntimePort(); + + render(); + + act(() => { + port.emit({ + type: 'playgroundNotification', + notification: { type: 'listProjects', projects: ['project'] }, + }); + port.emit({ + type: 'playgroundNotification', + notification: { + type: 'updateProject', + project: 'project', + update: { + isBexCurrent: true, + functions: [ + { + name: 'EchoEnvelope', + kind: 'expr', + origin: 'userDefined', + params: [ + { + name: 'envelope', + hasDefault: false, + schema: { type: 'ref', name: 'user.Envelope' }, + }, + ], + }, + ], + types: { + 'user.Flag': { + kind: 'class', + fields: [{ name: 'active', schema: { type: 'bool' } }], + }, + 'user.Envelope': { + kind: 'class', + fields: [ + { + name: 'flag', + schema: { type: 'ref', name: 'user.Flag' }, + }, + ], + }, + }, + diagnostics: [], + }, + }, + }); + }); + + fireEvent.click( + await screen.findByRole('button', { name: 'Functions (1)' }), + ); + fireEvent.click( + await screen.findByRole('button', { name: 'EchoEnvelope' }), + ); + + // The switch is untouched and visually false; raw state must already + // contain that same false value rather than a marker-only nested class. + expect(await screen.findByRole('switch')).not.toBeChecked(); + fireEvent.click(await screen.findByRole('button', { name: 'raw' })); + const rawInput = await screen.findByPlaceholderText('{"key": "value"}'); + await waitFor(() => { + expect(JSON.parse((rawInput as HTMLInputElement).value)).toEqual({ + envelope: { + $baml: { type: 'user.Envelope' }, + flag: { + $baml: { type: 'user.Flag' }, + active: false, + }, + }, + }); + }); + }); + it('keeps an explicitly chosen union variant selected when values overlap', async () => { const port = new FakeRuntimePort(); diff --git a/typescript2/pkg-playground/src/ExecutionPanel.tsx b/typescript2/pkg-playground/src/ExecutionPanel.tsx index 13dccc8641b..726fa35a995 100644 --- a/typescript2/pkg-playground/src/ExecutionPanel.tsx +++ b/typescript2/pkg-playground/src/ExecutionPanel.tsx @@ -51,9 +51,8 @@ import type { import type { ResultRendererProps } from './result-renderers'; import { ArgsForm } from './ArgsForm'; import { - defaultValueForSchema, isPlainObject, - normalizeArgs, + reconcileArgs, typeLookupFrom, } from './args-form-model'; import { ResultDisplay } from './ResultDisplay'; @@ -1777,43 +1776,6 @@ export const ExecutionPanel: FC = ({ [logsPanelHeight], ); - const onRunFunction = useCallback(async () => { - if (!selectedFn || !selectedProject || isRunning) return; - - // Don't force the 'run' tab — running keeps the user on whatever tab - // they're viewing (graph, trace, prompt, etc.). - setExpandedLogId(null); - setRunValidationError(null); - - requestAnimationFrame(() => { - outputRef.current?.scrollTo({ top: 0, behavior: 'smooth' }); - }); - - try { - const parsed = JSON.parse(argsJson); - if ( - typeof parsed !== 'object' || - parsed === null || - Array.isArray(parsed) - ) { - throw new Error( - 'Arguments must be a JSON object, e.g. {"arr": [3,1,2]}', - ); - } - const argsBytes = encodeRunArgs(parsed as Record); - - const boundaryId = await executionStore.startRun({ - project: selectedProject, - functionName: selectedFn, - argsBytes: new Uint8Array(argsBytes), - }); - setArgsJsonByBoundaryId((prev) => ({ ...prev, [boundaryId]: argsJson })); - } catch (e) { - const errMsg = e instanceof Error ? e.message : String(e); - setRunValidationError(errMsg); - } - }, [selectedFn, selectedProject, argsJson, isRunning, executionStore]); - const handleRefreshTests = useCallback(() => { if (!selectedProject) return; port.postMessage({ type: 'requestCollectTests', project: selectedProject }); @@ -2047,6 +2009,10 @@ export const ExecutionPanel: FC = ({ () => typeLookupFrom(projectTypes), [projectTypes], ); + const argsSchemaKey = JSON.stringify([ + paramSchemas ?? null, + projectTypes ?? null, + ]); // The form can only render args that parse to a plain JSON object; anything // else (mid-edit raw JSON, array) falls back to the raw input with a notice // instead of destroying the user's text. @@ -2061,66 +2027,36 @@ export const ExecutionPanel: FC = ({ } return null; }, [argsJson]); + const reconciledFormArgs = useMemo(() => { + if (parsedArgs === null || paramSchemas === undefined) return null; + return reconcileArgs(parsedArgs, paramSchemas, typeLookup); + }, [parsedArgs, paramSchemas, typeLookup]); const showArgsForm = argsMode === 'form' && paramSchemas !== undefined && parsedArgs !== null; const argsFormUnavailable = argsMode === 'form' && paramSchemas !== undefined && parsedArgs === null; - // Seed empty args with schema defaults, once per function per session. This - // is what injects `$baml` class markers and required keys without the user - // touching every field. Skipped when the function already has typed args or - // a host seed. Deliberately does NOT read `argsJson`/`parsedArgs` — on the - // render where `selectedFn` changes those still reflect the previous - // function (the swap effect's setState lands next render), and reading them - // here used to clobber just-restored host seeds with machine defaults. - const seededFnsRef = useRef>(new Set()); - useEffect(() => { - if (!selectedFn || !paramSchemas || paramSchemas.length === 0) return; - if (seededFnsRef.current.has(selectedFn)) return; - if (typedArgsByFnRef.current[selectedFn] !== undefined) return; - try { - const base: unknown = JSON.parse(baseArgsFor(selectedFn)); - if (!isPlainObject(base) || Object.keys(base).length > 0) { - return; // a host seed exists — never overwrite it - } - } catch { - return; // leave an unparseable host seed for the user to see and fix - } - seededFnsRef.current.add(selectedFn); - const seeded: Record = {}; - for (const param of paramSchemas) { - if (!param.hasDefault) { - seeded[param.name] = defaultValueForSchema(param.schema, typeLookup); - } - } - // Write through the shared setter so the seed also lands in - // typedArgsByFnRef — otherwise switching away and back restores '{}' and - // the seeded defaults/markers are lost. - updateArgsJson(JSON.stringify(seeded)); - }, [selectedFn, paramSchemas, typeLookup, baseArgsFor, updateArgsJson]); - - // Normalize wire markers once per function while form mode is active: bare - // enum strings (hand-edited raw JSON, host seeds, pre-marker session - // memory) and markerless class objects render as typed widgets but would - // encode untyped (string / mapValue) — no String→Enum coercion exists on - // the args path. Rewriting through the shared setter keeps argsJson - // matching what the widgets display. Reads the authoritative args via - // baseArgsFor, never argsJson state (stale on the selection-change - // commit). Raw mode re-arms it, so hand edits get re-normalized on the - // way back into form mode. - const normalizeStateRef = useRef<{ fn: string | null; done: boolean }>({ - fn: null, + // Reconcile once for each project/function/schema combination while form + // mode is active. This replaces the old function-only seed and normalize + // guards, which never re-ran when a same-name function changed type. + const argsSchemaScope = selectedFn + ? JSON.stringify([selectedProject ?? null, selectedFn, argsSchemaKey]) + : null; + const reconcileStateRef = useRef<{ scope: string | null; done: boolean }>({ + scope: null, done: false, }); useEffect(() => { - const state = normalizeStateRef.current; + const state = reconcileStateRef.current; if (argsMode === 'raw') { state.done = false; return; } - if (!selectedFn || !paramSchemas) return; - if (state.fn === selectedFn && state.done) return; - normalizeStateRef.current = { fn: selectedFn, done: true }; + if (!selectedFn || paramSchemas === undefined || argsSchemaScope === null) { + return; + } + if (state.scope === argsSchemaScope && state.done) return; + reconcileStateRef.current = { scope: argsSchemaScope, done: true }; let args: unknown; try { args = JSON.parse(baseArgsFor(selectedFn)); @@ -2128,18 +2064,75 @@ export const ExecutionPanel: FC = ({ return; // not form-renderable; the raw fallback shows it as-is } if (!isPlainObject(args)) return; - const normalized = normalizeArgs(args, paramSchemas, typeLookup); - if (normalized !== args) { - updateArgsJson(JSON.stringify(normalized)); + const reconciled = reconcileArgs(args, paramSchemas, typeLookup); + if (reconciled !== args) { + updateArgsJson(JSON.stringify(reconciled)); } }, [ argsMode, selectedFn, paramSchemas, typeLookup, + argsSchemaScope, baseArgsFor, updateArgsJson, ]); + + const onRunFunction = useCallback(async () => { + if (!selectedFn || !selectedProject || isRunning) return; + + // Don't force the 'run' tab — running keeps the user on whatever tab + // they're viewing (graph, trace, prompt, etc.). + setExpandedLogId(null); + setRunValidationError(null); + + requestAnimationFrame(() => { + outputRef.current?.scrollTo({ top: 0, behavior: 'smooth' }); + }); + + try { + const parsed: unknown = JSON.parse(argsJson); + if (!isPlainObject(parsed)) { + throw new Error( + 'Arguments must be a JSON object, e.g. {"arr": [3,1,2]}', + ); + } + // Effects normally keep form state canonical, but Run must also close + // the same-commit race after a project/schema update. Raw mode remains + // an exact escape hatch and is intentionally not reconciled here. + const runArgs = + argsMode === 'form' && paramSchemas !== undefined + ? reconcileArgs(parsed, paramSchemas, typeLookup) + : parsed; + const runArgsJson = + runArgs === parsed ? argsJson : JSON.stringify(runArgs); + if (runArgs !== parsed) updateArgsJson(runArgsJson); + const argsBytes = encodeRunArgs(runArgs); + + const boundaryId = await executionStore.startRun({ + project: selectedProject, + functionName: selectedFn, + argsBytes: new Uint8Array(argsBytes), + }); + setArgsJsonByBoundaryId((prev) => ({ + ...prev, + [boundaryId]: runArgsJson, + })); + } catch (e) { + const errMsg = e instanceof Error ? e.message : String(e); + setRunValidationError(errMsg); + } + }, [ + selectedFn, + selectedProject, + argsJson, + argsMode, + paramSchemas, + typeLookup, + isRunning, + executionStore, + updateArgsJson, + ]); // Names of LLM functions — only these have a meaningful raw (un-parsed LLM // output) vs parsed distinction, so the Parsed/Raw toggle is shown only for // them. expr functions just return a structured value (raw == parsed). @@ -3190,16 +3183,15 @@ export const ExecutionPanel: FC = ({ )} - {showArgsForm && paramSchemas && parsedArgs && ( + {showArgsForm && paramSchemas && reconciledFormArgs && (
- {/* Key by function: the swap effect replaces argsJson - externally on selection change; remounting resets - widget drafts/collapse state with it. */} + {/* Remount on function or schema changes so local widget + drafts cannot outlive the schema they represent. */}
diff --git a/typescript2/pkg-playground/src/args-form-model.test.ts b/typescript2/pkg-playground/src/args-form-model.test.ts index 856415ba1c2..1ad6905a1e7 100644 --- a/typescript2/pkg-playground/src/args-form-model.test.ts +++ b/typescript2/pkg-playground/src/args-form-model.test.ts @@ -7,7 +7,7 @@ import { enumVariantOf, isEnumMarkerValue, isRawJsonSchema, - normalizeArgs, + reconcileArgs, resolveRef, schemaLabel, typeLookupFrom, @@ -40,6 +40,16 @@ const types: Record = { }, ], }, + 'user.RequiredNode': { + kind: 'class', + fields: [ + { name: 'value', schema: { type: 'int' } }, + { + name: 'next', + schema: { type: 'ref', name: 'user.RequiredNode' }, + }, + ], + }, // Recursive alias: type JSON = string | JSON[] 'user.JSON': { kind: 'alias', @@ -121,13 +131,13 @@ describe('defaultValueForSchema', () => { ).toEqual({}); }); - it('seeds a class one level deep: marker + field defaults, nested class refs marker-only', () => { + it('seeds required nested classes with every displayed field default', () => { expect(defaultValueForSchema(personRef, lookup)).toEqual({ $baml: { type: 'user.Person' }, name: '', age: null, color: { $baml: { enum: 'user.Color', value: 'Red' } }, - nested: { $baml: { type: 'user.Nested' } }, + nested: { $baml: { type: 'user.Nested' }, x: 0 }, }); }); @@ -308,7 +318,7 @@ describe('isRawJsonSchema', () => { }); }); -describe('normalizeArgs', () => { +describe('reconcileArgs', () => { const params: ParamSchema[] = [ { name: 'c', hasDefault: false, schema: colorRef }, { name: 'p', hasDefault: false, schema: personRef }, @@ -320,28 +330,34 @@ describe('normalizeArgs', () => { ]; it('rewrites bare enum strings that name a valid variant', () => { - expect(normalizeArgs({ c: 'Red' }, params, lookup)).toEqual({ + expect(reconcileArgs({ c: 'Red' }, [params[0]], lookup)).toEqual({ c: enumValue('user.Color', 'Red'), }); // Inside containers too. - expect(normalizeArgs({ list: ['Red', 'Blue'] }, params, lookup)).toEqual({ - list: [enumValue('user.Color', 'Red'), enumValue('user.Color', 'Blue')], + expect(reconcileArgs({ list: ['Red', 'Blue'] }, [params[2]], lookup)) + .toEqual({ + list: [enumValue('user.Color', 'Red'), enumValue('user.Color', 'Blue')], + }); + // An incompatible stale value resets to the schema default. + expect(reconcileArgs({ c: 'Magenta' }, [params[0]], lookup)).toEqual({ + c: enumValue('user.Color', 'Red'), }); - // Not when the string is not a variant. - const invalid = { c: 'Magenta' }; - expect(normalizeArgs(invalid, params, lookup)).toBe(invalid); }); it('injects marker and missing-field defaults into markerless class objects', () => { expect( - normalizeArgs({ p: { name: 'Ada', color: 'Green' } }, params, lookup), + reconcileArgs( + { p: { name: 'Ada', color: 'Green' } }, + [params[1]], + lookup, + ), ).toEqual({ p: { $baml: { type: 'user.Person' }, name: 'Ada', color: enumValue('user.Color', 'Green'), age: null, - nested: { $baml: { type: 'user.Nested' } }, + nested: { $baml: { type: 'user.Nested' }, x: 0 }, }, }); }); @@ -350,29 +366,118 @@ describe('normalizeArgs', () => { // Raw editing can leave `$baml: 5` behind; spreading it over the marker // would silently keep the value untyped while widgets render it typed. expect( - normalizeArgs({ p: { $baml: 5, name: 'x' } }, params, lookup), + reconcileArgs( + { p: { $baml: 5, name: 'x' } }, + [params[1]], + lookup, + ), ).toMatchObject({ p: { $baml: { type: 'user.Person' }, name: 'x' }, }); }); - it('leaves marker-carrying objects structurally alone (normalizes present fields only)', () => { + it('fills marker-carrying objects and is idempotent once complete', () => { const args = { p: { $baml: { type: 'user.Person' }, color: 'Blue' }, }; - expect(normalizeArgs(args, params, lookup)).toEqual({ - p: { $baml: { type: 'user.Person' }, color: enumValue('user.Color', 'Blue') }, + expect(reconcileArgs(args, [params[1]], lookup)).toEqual({ + p: { + $baml: { type: 'user.Person' }, + name: '', + age: null, + color: enumValue('user.Color', 'Blue'), + nested: { $baml: { type: 'user.Nested' }, x: 0 }, + }, }); // Idempotent: already-typed input comes back by reference. const typed = { - c: enumValue('user.Color', 'Red'), - p: { $baml: { type: 'user.Person' }, name: 'Ada' }, + p: { + $baml: { type: 'user.Person' }, + name: 'Ada', + age: null, + color: enumValue('user.Color', 'Red'), + nested: { $baml: { type: 'user.Nested' }, x: 0 }, + }, }; - expect(normalizeArgs(typed, params, lookup)).toBe(typed); + expect(reconcileArgs(typed, [params[1]], lookup)).toBe(typed); }); it('preserves surplus keys and values without schemas', () => { const args = { c: enumValue('user.Color', 'Red'), extra: { a: 1 } }; - expect(normalizeArgs(args, params, lookup)).toBe(args); + expect(reconcileArgs(args, [params[0]], lookup)).toBe(args); + }); + + it('adds required params while leaving declared defaults omitted', () => { + const requiredAndDefaulted: ParamSchema[] = [ + { name: 'required', hasDefault: false, schema: { type: 'int' } }, + { name: 'declared', hasDefault: true, schema: { type: 'bool' } }, + ]; + expect(reconcileArgs({}, requiredAndDefaulted, lookup)).toEqual({ + required: 0, + }); + }); + + it('migrates compatible values when a class gains a required field', () => { + const migratedLookup = typeLookupFrom({ + 'user.Person': { + kind: 'class', + fields: [ + { name: 'name', schema: { type: 'string' } }, + { name: 'active', schema: { type: 'bool' } }, + { name: 'age', schema: { type: 'int' } }, + ], + }, + }); + expect( + reconcileArgs( + { + p: { + $baml: { type: 'user.Person' }, + name: 'Ada', + active: false, + }, + }, + [{ name: 'p', hasDefault: false, schema: personRef }], + migratedLookup, + ), + ).toEqual({ + p: { + $baml: { type: 'user.Person' }, + name: 'Ada', + active: false, + age: 0, + }, + }); + }); + + it('resets a value that no longer matches the parameter type', () => { + expect( + reconcileArgs( + { p: { $baml: { type: 'user.Person' }, name: 'Ada' } }, + [{ name: 'p', hasDefault: false, schema: { type: 'int' } }], + lookup, + ), + ).toEqual({ p: 0 }); + }); + + it('does not grow a required recursive cut point on repeated reconciliation', () => { + const node = defaultValueForSchema( + { type: 'ref', name: 'user.RequiredNode' }, + lookup, + ); + const args = { node }; + expect( + reconcileArgs( + args, + [ + { + name: 'node', + hasDefault: false, + schema: { type: 'ref', name: 'user.RequiredNode' }, + }, + ], + lookup, + ), + ).toBe(args); }); }); diff --git a/typescript2/pkg-playground/src/args-form-model.ts b/typescript2/pkg-playground/src/args-form-model.ts index c1a9e925cff..c0952d4c25d 100644 --- a/typescript2/pkg-playground/src/args-form-model.ts +++ b/typescript2/pkg-playground/src/args-form-model.ts @@ -83,8 +83,8 @@ function classMarkerOf(value: unknown): string | undefined { return undefined; } -/** Marker-only class instance — what nested class refs seed; fields fill in - * as the user edits them (`ClassSection` injects the marker on every edit). */ +/** Marker-only class instance — used as the finite cut point for a required + * recursive class path that cannot be populated to arbitrary depth. */ export function classMarkerValue(name: string): Record { return { $baml: { type: name } }; } @@ -130,26 +130,25 @@ export function resolveRef(name: string, lookup: TypeLookup): ResolvedRef { /** A sensible zero value for a schema node, used to seed new list rows, * freshly-enabled optionals, union-variant switches, and empty args. * - * Seeding depth rule: a class ref expands **one level** — marker plus - * defaults for its immediate fields, where a nested class ref seeds - * marker-only. Deeper levels fill in as the user opens sections and edits - * fields (`ClassSection` injects the marker on edit). Eager deep seeding - * would reintroduce the payload blow-up on DAG-shaped class graphs - * client-side. */ + * Required acyclic class refs are populated recursively so every default a + * typed widget displays is also present in the serialized value. A class ref + * already active on the current path becomes marker-only, which is the + * finite cut point for an irreducibly required recursive type. Optional and + * container branches terminate naturally at null/empty values. */ export function defaultValueForSchema( schema: FieldSchema, lookup: TypeLookup, ): unknown { - return defaultValue(schema, lookup, 0, new Set()); + return defaultValue(schema, lookup, new Set(), new Set()); } function defaultValue( schema: FieldSchema, lookup: TypeLookup, - classDepth: number, + classPath: Set, /** Ref names already hopped through at this level (alias chains); guards * pathological pure-ref cycles the extractor shouldn't emit. */ - seen: Set, + aliasPath: Set, ): unknown { switch (schema.type) { case 'string': @@ -167,7 +166,7 @@ function defaultValue( case 'enumVariant': return enumValue(schema.name, schema.value); case 'ref': { - if (seen.has(schema.name)) return null; + if (aliasPath.has(schema.name)) return null; const resolved = resolveRef(schema.name, lookup); if (resolved === undefined) return null; if (resolved.kind === 'enum') { @@ -176,17 +175,21 @@ function defaultValue( : null; } if (resolved.kind === 'schema') { - const next = new Set(seen); + const next = new Set(aliasPath); next.add(schema.name); - return defaultValue(resolved.schema, lookup, classDepth, next); + return defaultValue(resolved.schema, lookup, classPath, next); + } + if (classPath.has(resolved.name)) { + return classMarkerValue(resolved.name); } - if (classDepth >= 1) return classMarkerValue(resolved.name); + const nextClassPath = new Set(classPath); + nextClassPath.add(resolved.name); const obj = classMarkerValue(resolved.name); for (const field of resolved.fields) { obj[field.name] = defaultValue( field.schema, lookup, - classDepth + 1, + nextClassPath, new Set(), ); } @@ -200,7 +203,7 @@ function defaultValue( return null; case 'union': return schema.variants.length > 0 - ? defaultValue(schema.variants[0], lookup, classDepth, seen) + ? defaultValue(schema.variants[0], lookup, classPath, aliasPath) : null; case 'media': case 'unsupported': @@ -213,8 +216,8 @@ function defaultValue( } /** Loose structural check: does `value` plausibly inhabit `schema`? Used to - * pick the active union variant and by hydration normalization - * ([`normalizeArgs`]) to route parsed raw JSON to the right variant. + * pick the active union variant and by schema reconciliation + * ([`reconcileArgs`]) to route parsed raw JSON to the right variant. * Marker-carrying values are matched by name; plain values by JS shape. * Raw-JSON nodes (unsupported/media/dangling refs/unknown tags) admit * anything. */ @@ -364,43 +367,59 @@ export function isRawJsonSchema( } /** - * Rewrite wire-untyped values into their typed marker forms so what the - * widgets display is what actually encodes: bare enum strings (hand-edited - * raw JSON, pre-marker session memory) become `$baml` enum markers, and - * markerless class objects get the class marker plus defaults for missing - * fields. Marker-carrying objects are left structurally alone (only their - * present fields are normalized) — hydration must not grow already-typed - * values. Returns the input reference unchanged when nothing needed fixing, - * so callers can cheaply detect a no-op. + * Reconcile serialized args with the current function schema so the values + * rendered by typed widgets are exactly the values that will be encoded. + * + * Compatible user values and surplus raw-JSON keys are preserved. Missing + * required parameters/fields receive schema defaults, incompatible values are + * replaced with defaults, and typed class/enum markers are normalized. + * Defaulted function parameters remain absent so the runtime evaluates their + * declared BAML defaults. Returns the input reference when no change is + * required, allowing callers to avoid redundant state writes. */ -export function normalizeArgs( +export function reconcileArgs( args: Record, params: ParamSchema[], lookup: TypeLookup, ): Record { - const schemaByName = new Map(params.map((p) => [p.name, p.schema])); - return mapObject(args, (key, value) => { - const schema = schemaByName.get(key); - return schema === undefined + const paramsByName = new Map(params.map((param) => [param.name, param])); + let reconciled = mapObject(args, (key, value) => { + const param = paramsByName.get(key); + return param === undefined ? value - : normalize(value, schema, lookup, new Set()); + : reconcileValue(value, param.schema, lookup, new Set(), new Set()); }); + + for (const param of params) { + if (!(param.name in reconciled) && !param.hasDefault) { + if (reconciled === args) reconciled = { ...args }; + reconciled[param.name] = defaultValueForSchema(param.schema, lookup); + } + } + + return reconciled; } -function normalize( +function reconcileValue( value: unknown, schema: FieldSchema, lookup: TypeLookup, /** Ref names hopped through without descending into a child value. */ - seen: Set, + aliasPath: Set, + /** Class names active on the current value path. */ + classPath: Set, ): unknown { + if (!valueMatchesSchema(value, schema, lookup)) { + return defaultValue(schema, lookup, classPath, aliasPath); + } + switch (schema.type) { case 'enumVariant': return typeof value === 'string' && value === schema.value ? enumValue(schema.name, value) : value; case 'ref': { - if (seen.has(schema.name)) return value; + if (aliasPath.has(schema.name)) return value; const resolved = resolveRef(schema.name, lookup); if (resolved === undefined) return value; if (resolved.kind === 'enum') { @@ -409,60 +428,95 @@ function normalize( : value; } if (resolved.kind === 'schema') { - const next = new Set(seen); + const next = new Set(aliasPath); next.add(schema.name); - return normalize(value, resolved.schema, lookup, next); + return reconcileValue( + value, + resolved.schema, + lookup, + next, + classPath, + ); } if (!isPlainObject(value) || isEnumMarkerValue(value)) return value; + const marker = classMarkerOf(value); + const recursiveClass = classPath.has(resolved.name); + const nextClassPath = new Set(classPath); + nextClassPath.add(resolved.name); const fieldSchemas = new Map( resolved.fields.map((f) => [f.name, f.schema]), ); - const normalized = mapObject(value, (key, fieldValue) => { + let reconciled = mapObject(value, (key, fieldValue) => { const fieldSchema = fieldSchemas.get(key); return fieldSchema === undefined ? fieldValue - : normalize(fieldValue, fieldSchema, lookup, new Set()); + : reconcileValue( + fieldValue, + fieldSchema, + lookup, + new Set(), + nextClassPath, + ); }); - if (classMarkerOf(value) !== undefined) return normalized; - // Marker spread last: a junk non-marker `$baml` key from raw editing - // must be overwritten, not preserved over the injected marker. - const withMarker: Record = { - ...normalized, - ...classMarkerValue(resolved.name), - }; - for (const field of resolved.fields) { - if (!(field.name in withMarker)) { - // Same one-level depth rule as seeding: these fields sit inside a - // class, so nested class refs default to marker-only. - withMarker[field.name] = defaultValue(field.schema, lookup, 1, new Set()); + if (marker !== resolved.name) { + // Marker spread last: a junk non-marker `$baml` key from raw editing + // must be overwritten, not preserved over the injected marker. + reconciled = { + ...reconciled, + ...classMarkerValue(resolved.name), + }; + } + if (!recursiveClass) { + for (const field of resolved.fields) { + if (!(field.name in reconciled)) { + if (reconciled === value) reconciled = { ...value }; + reconciled[field.name] = defaultValue( + field.schema, + lookup, + nextClassPath, + new Set(), + ); + } } } - return withMarker; + return reconciled; } case 'list': { if (!Array.isArray(value)) return value; const items = value.map((item) => - normalize(item, schema.item, lookup, new Set()), + reconcileValue(item, schema.item, lookup, new Set(), classPath), ); return items.some((item, i) => item !== value[i]) ? items : value; } case 'map': { if (!valueMatchesSchema(value, schema, lookup)) return value; return mapObject(value as Record, (_key, entry) => - normalize(entry, schema.value, lookup, new Set()), + reconcileValue(entry, schema.value, lookup, new Set(), classPath), ); } case 'optional': return value === null ? value - : normalize(value, schema.inner, lookup, seen); + : reconcileValue( + value, + schema.inner, + lookup, + aliasPath, + classPath, + ); case 'union': { const active = schema.variants.findIndex((v) => valueMatchesSchema(value, v, lookup), ); return active === -1 - ? value - : normalize(value, schema.variants[active], lookup, seen); + ? defaultValue(schema, lookup, classPath, aliasPath) + : reconcileValue( + value, + schema.variants[active], + lookup, + aliasPath, + classPath, + ); } default: return value;