Skip to content

Expand discriminated unions in action templates - #2788

Draft
datduyng wants to merge 3 commits into
mainfrom
domnguyen/actionTemplate-union-arms
Draft

Expand discriminated unions in action templates#2788
datduyng wants to merge 3 commits into
mainfrom
domnguyen/actionTemplate-union-arms

Conversation

@datduyng

@datduyng datduyng commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Before (broken)

toTemplateType always collapsed every type-union to the first arm:

function toTemplateType(
    type: ActionParamType,
    visited: ReadonlySet<string> = new Set<string>(),
): TemplateType | undefined {
    switch (type.type) {
        case "type-union":
            // TODO: smarter about type unions.
            return toTemplateType(type.types[0], visited);  // ← always arm 0
        // ...
    }
}

// parameters built with no current values:
const type = toTemplateType(actionParametersType);

For player MusicTarget, that means the form template only ever sees the track arm:

// playerSchema.ts — 7 authored arms
export type MusicTarget =
    | PlayByTrack      // kind: "track"
    | PlayByArtist     // kind: "artist"
    | PlayByAlbum      // kind: "album"
    | PlayByGenre      // kind: "genre"
    | PlayByPlaylist   // kind: "playlist"
    | PlayByDescription// kind: "description"
    | PlayAnyMusic;    // kind: "any"

export interface PlayByTrack {
    kind: "track";
    trackName: string;
    artists?: string[];
    albumName?: string;
}
export interface PlayByArtist {
    kind: "artist";
    artist: string;
    genre?: string;
}
export interface PlayByAlbum {
    kind: "album";
    albumName: string;
    artists?: string[];
}
// ... genre / playlist / description / any
// template produced BEFORE (union → types[0] only)
{
  type: "object",
  fields: {
    kind:      { type: { type: "string-union", typeEnum: ["track"] } }, // ← only track
    trackName: { type: { type: "string" } },
    artists:   { optional: true, type: { type: "array", elementType: { type: "string" } } },
    albumName: { optional: true, type: { type: "string" } },
  }
}
// lost: artist | album | genre | playlist | description | any
kind enum visible in template: [ 'track' ]
template only exposes first kind: [ 'track' ]
lost arms: [ 'artist', 'album', 'genre', 'playlist', 'description', 'any' ]
RESULT: BUG REPRODUCED

After (fixed)

Pass current parameter data into template conversion, detect MusicTarget-style discriminated object unions, emit the full kind enum, and select arm fields from data.kind:

// Wire current values so the selected arm can be resolved
const type = toTemplateType(
    actionParametersType,
    new Set(),
    action.parameters, // ← was missing
);
function toTemplateType(
    type: ActionParamType,
    visited: ReadonlySet<string> = new Set<string>(),
    data: unknown = undefined,
): TemplateType | undefined {
    switch (type.type) {
        case "type-union":
            return toTemplateTypeUnion(type.types, visited, data);
        case "type-reference":
            // ... resolve, pass data through ...
            return toTemplateType(type.definition.type, nextVisited, data);
        case "object":
            return toTemplateTypeObject(type, visited, data);
        case "array":
            return toTemplateTypeArray(type, visited, data);
        // ...
    }
}
function toTemplateTypeUnion(
    types: readonly ActionParamType[],
    visited: ReadonlySet<string>,
    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<string, unknown>)[fieldName];
            if (typeof current === "string") {
                const idx = values.indexOf(current);
                if (idx >= 0) selectedIndex = idx;
            }
        }
        const template = toTemplateTypeObject(arms[selectedIndex], 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,              // all 7 kinds
                discriminator: values[selectedIndex],
            },
        };
        return template;
    }
    // Non-discriminated: first-arm fallback (historical behavior)
    return toTemplateType(types[0], visited, data);
}
// Discriminator discovery: every arm is an object with the same single-value
// string-union field (e.g. kind: "track" | kind: "artist" | …)
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);
    }
    for (const fieldName of Object.keys(arms[0].fields)) {
        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;
        if (new Set(values).size !== values.length) continue; // must uniquely select
        return { fieldName, values, arms };
    }
    return undefined;
}
// template produced AFTER — data.kind = "album"
{
  type: "object",
  fields: {
    kind: {
      type: {
        type: "string-union",
        typeEnum: [
          "track", "artist", "album", "genre",
          "playlist", "description", "any",
        ],
        discriminator: "album",
      },
    },
    albumName: { type: { type: "string" } },
    artists:   { optional: true, type: { type: "array", elementType: { type: "string" } } },
  }
}
// AFTER — data.kind = "track"
fields: [ "kind", "trackName", "artists", "albumName" ]
kind enum: [ track, artist, album, genre, playlist, description, any ]
discriminator: "track"

// AFTER — data.kind = "artist"
fields: [ "kind", "artist", "genre" ]
discriminator: "artist"

// AFTER — data.kind = "album"
fields: [ "kind", "albumName", "artists" ]
discriminator: "album"
RESULT: FIX VERIFIED
full kind enum on template: [track, artist, album, genre, playlist, description, any]

datduyng and others added 2 commits August 2, 2026 23:33
- 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
@datduyng
datduyng marked this pull request as draft August 3, 2026 17:08
- Drop long JSDoc blocks on union helpers
- Leave short line comments only
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant