From 79d9d1efaec2914016820d6e150c9ad4e80c7ed2 Mon Sep 17 00:00:00 2001 From: Dominic Nguyen Date: Sun, 2 Aug 2026 23:33:42 -0700 Subject: [PATCH 1/3] fix(dispatcher): expand discriminated unions in action templates - type-union previously always took types[0] (MusicTarget kind=[track] only) - Detect MusicTarget-style object unions via single-value kind discriminators - Emit full kind string-union + arm fields selected from current data.kind - Enables form/edit UI to switch arms via getTemplateSchema refresh - Tests for full kind enum, album/track arm selection, non-union unchanged --- .../src/translation/actionTemplate.ts | 188 +++++++++++++++++- .../dispatcher/test/actionTemplate.spec.ts | 128 ++++++++++++ 2 files changed, 307 insertions(+), 9 deletions(-) diff --git a/ts/packages/dispatcher/dispatcher/src/translation/actionTemplate.ts b/ts/packages/dispatcher/dispatcher/src/translation/actionTemplate.ts index f6056173a2..90c8e5b809 100644 --- a/ts/packages/dispatcher/dispatcher/src/translation/actionTemplate.ts +++ b/ts/packages/dispatcher/dispatcher/src/translation/actionTemplate.ts @@ -20,6 +20,7 @@ import { ActionParamArray, ActionParamObject, ActionParamType, + resolveTypeReference, } from "@typeagent/action-schema"; import { getActionParamCompletion } from "./requestCompletion.js"; @@ -43,22 +44,91 @@ function getDefaultActionTemplate( return template; } +/** + * Resolve a schema type through references to a concrete object, or undefined. + */ +function resolveObjectType( + type: ActionParamType, +): ActionParamObject | undefined { + const resolved = resolveTypeReference(type); + return resolved?.type === "object" ? resolved : undefined; +} + +/** + * For a discriminated object union (each arm is an object with the same + * single-value string-union field, e.g. `kind: "track" | …`), return the + * shared field name and every arm's discriminator value. Returns undefined + * when the union is not a clean discriminated object union. + */ +function getObjectUnionDiscriminator( + types: readonly ActionParamType[], +): { fieldName: string; values: string[]; arms: ActionParamObject[] } | undefined { + if (types.length < 2) { + return undefined; + } + const arms: ActionParamObject[] = []; + for (const t of types) { + const obj = resolveObjectType(t); + if (obj === undefined) { + return undefined; + } + arms.push(obj); + } + + // Candidate discriminator fields: present on every arm as a single-value + // string-union (or single-enum string-union). + const firstFields = Object.keys(arms[0].fields); + for (const fieldName of firstFields) { + const values: string[] = []; + let ok = true; + for (const arm of arms) { + const field = arm.fields[fieldName]; + if (field === undefined || field.optional) { + ok = false; + break; + } + const ft = resolveTypeReference(field.type) ?? field.type; + if (ft.type !== "string-union" || ft.typeEnum.length !== 1) { + ok = false; + break; + } + values.push(ft.typeEnum[0]); + } + if (!ok) { + continue; + } + // All values must be unique so kind uniquely selects an arm. + if (new Set(values).size !== values.length) { + continue; + } + return { fieldName, values, arms }; + } + return undefined; +} + function toTemplateTypeObject( type: ActionParamObject, visited: ReadonlySet, + data: unknown, ) { const templateType: TemplateFieldObject = { type: "object", fields: {}, }; + const dataObj = + data !== null && typeof data === "object" && !Array.isArray(data) + ? (data as Record) + : undefined; + for (const [key, field] of Object.entries(type.fields)) { - const type = toTemplateType(field.type, visited); - if (type === undefined) { + const fieldData = dataObj !== undefined ? dataObj[key] : undefined; + const fieldType = toTemplateType(field.type, visited, fieldData); + if (fieldType === undefined) { // Skip undefined fields. continue; } - templateType.fields[key] = { optional: field.optional, type }; + templateType.fields[key] = { optional: field.optional, type: fieldType }; } return templateType; } @@ -66,8 +136,11 @@ function toTemplateTypeObject( function toTemplateTypeArray( type: ActionParamArray, visited: ReadonlySet, + data: unknown, ) { - const elementType = toTemplateType(type.elementType, visited); + // Use the first element as a shape hint when present. + const elementData = Array.isArray(data) && data.length > 0 ? data[0] : undefined; + const elementType = toTemplateType(type.elementType, visited, elementData); if (elementType === undefined) { // Skip undefined fields. return undefined; @@ -79,14 +152,104 @@ function toTemplateTypeArray( return templateType; } +/** + * Convert a type-union into a template field. + * + * Discriminated object unions (MusicTarget-style `kind: "track" | "artist" | …`) + * become an object template for the arm matching `data`, with the discriminator + * field expanded to the full string-union so the editor can switch arms via + * `getTemplateSchema` refresh. + * + * Non-discriminated unions fall back to the arm that validates against `data`, + * or the first arm when data is absent — same as the historical first-arm + * behavior for empty templates. + */ +function toTemplateTypeUnion( + types: readonly ActionParamType[], + visited: ReadonlySet, + data: unknown, +): TemplateType | undefined { + const disc = getObjectUnionDiscriminator(types); + if (disc !== undefined) { + const { fieldName, values, arms } = disc; + let selectedIndex = 0; + if ( + data !== null && + typeof data === "object" && + !Array.isArray(data) && + fieldName in (data as object) + ) { + const current = (data as Record)[fieldName]; + if (typeof current === "string") { + const idx = values.indexOf(current); + if (idx >= 0) { + selectedIndex = idx; + } + } + } + const selectedArm = arms[selectedIndex]; + const template = toTemplateTypeObject(selectedArm, visited, data); + // Full enum so the UI can switch arms; discriminator triggers schema refresh. + template.fields[fieldName] = { + optional: false, + type: { + type: "string-union", + typeEnum: values, + discriminator: values[selectedIndex], + }, + }; + return template; + } + + // Non-discriminated: prefer an arm that structurally matches current data. + if (data !== undefined) { + for (const t of types) { + const resolved = resolveTypeReference(t) ?? t; + try { + // Lightweight structural probe — prefer object when data is object, etc. + if ( + resolved.type === "object" && + data !== null && + typeof data === "object" && + !Array.isArray(data) + ) { + return toTemplateType(t, visited, data); + } + if (resolved.type === "array" && Array.isArray(data)) { + return toTemplateType(t, visited, data); + } + if ( + (resolved.type === "string" || + resolved.type === "number" || + resolved.type === "boolean" || + resolved.type === "string-union") && + typeof data === resolved.type + ) { + return toTemplateType(t, visited, data); + } + if ( + resolved.type === "string-union" && + typeof data === "string" + ) { + return toTemplateType(t, visited, data); + } + } catch { + // try next arm + } + } + } + // Historical fallback: first arm. + return toTemplateType(types[0], visited, data); +} + function toTemplateType( type: ActionParamType, visited: ReadonlySet = new Set(), + data: unknown = undefined, ): TemplateType | undefined { switch (type.type) { case "type-union": - // TODO: smarter about type unions. - return toTemplateType(type.types[0], visited); + return toTemplateTypeUnion(type.types, visited, data); case "type-reference": if (type.definition === undefined) { throw new Error(`Unresolved type reference: ${type.name}`); @@ -98,11 +261,12 @@ function toTemplateType( return toTemplateType( type.definition.type, new Set([...visited, type.name]), + data, ); case "object": - return toTemplateTypeObject(type, visited); + return toTemplateTypeObject(type, visited, data); case "array": - return toTemplateTypeArray(type, visited); + return toTemplateTypeArray(type, visited, data); case "string-union": return type as TemplateFieldStringUnion; case "string": @@ -153,7 +317,13 @@ function toTemplate( const actionParametersType = actionSchema.type.fields.parameters?.type; if (actionParametersType) { - const type = toTemplateType(actionParametersType); + // Pass current parameter values so discriminated unions (e.g. MusicTarget) + // expand to the arm matching data and expose the full kind enum. + const type = toTemplateType( + actionParametersType, + new Set(), + action.parameters, + ); if (type !== undefined) { template.fields.parameters = { // ActionParam types are compatible with TemplateFields diff --git a/ts/packages/dispatcher/dispatcher/test/actionTemplate.spec.ts b/ts/packages/dispatcher/dispatcher/test/actionTemplate.spec.ts index 23ada756d0..3fc96b8672 100644 --- a/ts/packages/dispatcher/dispatcher/test/actionTemplate.spec.ts +++ b/ts/packages/dispatcher/dispatcher/test/actionTemplate.spec.ts @@ -92,3 +92,131 @@ describe("getSystemTemplateSchema toTemplateType", () => { expect(Object.keys(parameters.fields)).toEqual(["keep"]); }); }); + +describe("getSystemTemplateSchema discriminated type-union", () => { + function musicTargetUnion(): ActionParamType { + const track = { + type: "object", + fields: { + kind: { + type: { type: "string-union", typeEnum: ["track"] }, + }, + trackName: { type: { type: "string" } }, + artists: { + optional: true, + type: { + type: "array", + elementType: { type: "string" }, + }, + }, + }, + }; + const artist = { + type: "object", + fields: { + kind: { + type: { type: "string-union", typeEnum: ["artist"] }, + }, + artistName: { type: { type: "string" } }, + }, + }; + const album = { + type: "object", + fields: { + kind: { + type: { type: "string-union", typeEnum: ["album"] }, + }, + albumName: { type: { type: "string" } }, + }, + }; + const playlist = { + type: "object", + fields: { + kind: { + type: { type: "string-union", typeEnum: ["playlist"] }, + }, + playlistName: { type: { type: "string" } }, + }, + }; + return { + type: "object", + fields: { + target: { + type: { + type: "type-union", + types: [track, artist, album, playlist], + }, + }, + }, + } as unknown as ActionParamType; + } + + it("exposes full kind enum for MusicTarget-style discriminated unions", async () => { + const template = await getSystemTemplateSchema( + "action", + { + schemaName, + actionName, + parameters: { + target: { kind: "track", trackName: "Bohemian Rhapsody" }, + }, + }, + makeContext(musicTargetUnion()), + ); + + const parameters = template.fields.parameters?.type as any; + const target = parameters.fields.target.type; + expect(target.type).toBe("object"); + expect(target.fields.kind.type).toEqual({ + type: "string-union", + typeEnum: ["track", "artist", "album", "playlist"], + discriminator: "track", + }); + expect(target.fields.trackName.type).toEqual({ type: "string" }); + expect(target.fields.artistName).toBeUndefined(); + }); + + it("selects album arm fields when data.kind is album", async () => { + const template = await getSystemTemplateSchema( + "action", + { + schemaName, + actionName, + parameters: { + target: { kind: "album", albumName: "A Night at the Opera" }, + }, + }, + makeContext(musicTargetUnion()), + ); + + const target = (template.fields.parameters?.type as any).fields.target + .type; + expect(target.fields.kind.type.typeEnum).toEqual([ + "track", + "artist", + "album", + "playlist", + ]); + expect(target.fields.kind.type.discriminator).toBe("album"); + expect(target.fields.albumName.type).toEqual({ type: "string" }); + expect(target.fields.trackName).toBeUndefined(); + expect(target.fields.playlistName).toBeUndefined(); + }); + + it("keeps non-union object parameters unchanged", async () => { + const parametersType: ActionParamType = { + type: "object", + fields: { + query: { type: { type: "string" } }, + }, + } as unknown as ActionParamType; + + const template = await getSystemTemplateSchema( + "action", + { ...data, parameters: { query: "hello" } }, + makeContext(parametersType), + ); + const parameters = template.fields.parameters?.type as any; + expect(parameters.fields.query.type).toEqual({ type: "string" }); + }); +}); From 1327d7ee8a0454ad45ec64138a0805ef86ad4d22 Mon Sep 17 00:00:00 2001 From: typeagent-bot Date: Mon, 3 Aug 2026 17:05:34 +0000 Subject: [PATCH 2/3] style: apply prettier formatting and policy fixes --- .../dispatcher/src/translation/actionTemplate.ts | 12 +++++++++--- .../dispatcher/test/actionTemplate.spec.ts | 5 ++++- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/ts/packages/dispatcher/dispatcher/src/translation/actionTemplate.ts b/ts/packages/dispatcher/dispatcher/src/translation/actionTemplate.ts index 90c8e5b809..b998d93d1d 100644 --- a/ts/packages/dispatcher/dispatcher/src/translation/actionTemplate.ts +++ b/ts/packages/dispatcher/dispatcher/src/translation/actionTemplate.ts @@ -62,7 +62,9 @@ function resolveObjectType( */ function getObjectUnionDiscriminator( types: readonly ActionParamType[], -): { fieldName: string; values: string[]; arms: ActionParamObject[] } | undefined { +): + | { fieldName: string; values: string[]; arms: ActionParamObject[] } + | undefined { if (types.length < 2) { return undefined; } @@ -128,7 +130,10 @@ function toTemplateTypeObject( // Skip undefined fields. continue; } - templateType.fields[key] = { optional: field.optional, type: fieldType }; + templateType.fields[key] = { + optional: field.optional, + type: fieldType, + }; } return templateType; } @@ -139,7 +144,8 @@ function toTemplateTypeArray( data: unknown, ) { // Use the first element as a shape hint when present. - const elementData = Array.isArray(data) && data.length > 0 ? data[0] : undefined; + const elementData = + Array.isArray(data) && data.length > 0 ? data[0] : undefined; const elementType = toTemplateType(type.elementType, visited, elementData); if (elementType === undefined) { // Skip undefined fields. diff --git a/ts/packages/dispatcher/dispatcher/test/actionTemplate.spec.ts b/ts/packages/dispatcher/dispatcher/test/actionTemplate.spec.ts index 3fc96b8672..002d0f368c 100644 --- a/ts/packages/dispatcher/dispatcher/test/actionTemplate.spec.ts +++ b/ts/packages/dispatcher/dispatcher/test/actionTemplate.spec.ts @@ -183,7 +183,10 @@ describe("getSystemTemplateSchema discriminated type-union", () => { schemaName, actionName, parameters: { - target: { kind: "album", albumName: "A Night at the Opera" }, + target: { + kind: "album", + albumName: "A Night at the Opera", + }, }, }, makeContext(musicTargetUnion()), From 9ce8b7decfa4718105ef0b1032681a89cd2b7e0c Mon Sep 17 00:00:00 2001 From: Dominic Nguyen Date: Mon, 3 Aug 2026 10:15:57 -0700 Subject: [PATCH 3/3] style(dispatcher): keep actionTemplate comments under 10 words - Drop long JSDoc blocks on union helpers - Leave short line comments only --- .../src/translation/actionTemplate.ts | 37 ++++--------------- 1 file changed, 7 insertions(+), 30 deletions(-) diff --git a/ts/packages/dispatcher/dispatcher/src/translation/actionTemplate.ts b/ts/packages/dispatcher/dispatcher/src/translation/actionTemplate.ts index b998d93d1d..e8f9d6e24c 100644 --- a/ts/packages/dispatcher/dispatcher/src/translation/actionTemplate.ts +++ b/ts/packages/dispatcher/dispatcher/src/translation/actionTemplate.ts @@ -44,9 +44,6 @@ function getDefaultActionTemplate( return template; } -/** - * Resolve a schema type through references to a concrete object, or undefined. - */ function resolveObjectType( type: ActionParamType, ): ActionParamObject | undefined { @@ -54,12 +51,7 @@ function resolveObjectType( return resolved?.type === "object" ? resolved : undefined; } -/** - * For a discriminated object union (each arm is an object with the same - * single-value string-union field, e.g. `kind: "track" | …`), return the - * shared field name and every arm's discriminator value. Returns undefined - * when the union is not a clean discriminated object union. - */ +// Discriminated object union: shared single-value kind field. function getObjectUnionDiscriminator( types: readonly ActionParamType[], ): @@ -77,8 +69,6 @@ function getObjectUnionDiscriminator( arms.push(obj); } - // Candidate discriminator fields: present on every arm as a single-value - // string-union (or single-enum string-union). const firstFields = Object.keys(arms[0].fields); for (const fieldName of firstFields) { const values: string[] = []; @@ -99,7 +89,7 @@ function getObjectUnionDiscriminator( if (!ok) { continue; } - // All values must be unique so kind uniquely selects an arm. + // Kind values must be unique per arm. if (new Set(values).size !== values.length) { continue; } @@ -143,7 +133,7 @@ function toTemplateTypeArray( visited: ReadonlySet, data: unknown, ) { - // Use the first element as a shape hint when present. + // First array element shapes nested templates. const elementData = Array.isArray(data) && data.length > 0 ? data[0] : undefined; const elementType = toTemplateType(type.elementType, visited, elementData); @@ -158,18 +148,7 @@ function toTemplateTypeArray( return templateType; } -/** - * Convert a type-union into a template field. - * - * Discriminated object unions (MusicTarget-style `kind: "track" | "artist" | …`) - * become an object template for the arm matching `data`, with the discriminator - * field expanded to the full string-union so the editor can switch arms via - * `getTemplateSchema` refresh. - * - * Non-discriminated unions fall back to the arm that validates against `data`, - * or the first arm when data is absent — same as the historical first-arm - * behavior for empty templates. - */ +// Expand discriminated unions; else first-arm fallback. function toTemplateTypeUnion( types: readonly ActionParamType[], visited: ReadonlySet, @@ -195,7 +174,7 @@ function toTemplateTypeUnion( } const selectedArm = arms[selectedIndex]; const template = toTemplateTypeObject(selectedArm, visited, data); - // Full enum so the UI can switch arms; discriminator triggers schema refresh. + // Full kind enum; arm fields from data. template.fields[fieldName] = { optional: false, type: { @@ -207,12 +186,11 @@ function toTemplateTypeUnion( return template; } - // Non-discriminated: prefer an arm that structurally matches current data. + // Prefer arm that matches current data shape. if (data !== undefined) { for (const t of types) { const resolved = resolveTypeReference(t) ?? t; try { - // Lightweight structural probe — prefer object when data is object, etc. if ( resolved.type === "object" && data !== null && @@ -323,8 +301,7 @@ function toTemplate( const actionParametersType = actionSchema.type.fields.parameters?.type; if (actionParametersType) { - // Pass current parameter values so discriminated unions (e.g. MusicTarget) - // expand to the arm matching data and expose the full kind enum. + // Pass data so union arms resolve correctly. const type = toTemplateType( actionParametersType, new Set(),