Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 24 additions & 5 deletions packages/base/enum.gts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ export function enumConfig<T>(
input:
| EnumConfigurationInput<T>
| ({ options?: any[]; unsetLabel?: string } | undefined)
| ((self: Readonly<T>) =>
| ((
self: Readonly<T>,
) =>
| EnumConfiguration
| { options?: any[]; unsetLabel?: string }
| undefined),
Expand All @@ -45,9 +47,9 @@ export function enumConfig<T>(
}

if (typeof input === 'function') {
return (function (this: Readonly<T>) {
return function (this: Readonly<T>) {
return normalize((input as any).call(this));
}) as any;
} as any;
}
return normalize(input as any) as EnumConfiguration;
}
Expand Down Expand Up @@ -98,7 +100,12 @@ export function enumValues(model: object, fieldName: string): any[] {

function enumField<BaseT extends FieldDefConstructor>(
Base: BaseT,
config: { options: any; displayName?: string; icon?: any },
config: {
options: any;
defaultOptions?: any[];
displayName?: string;
icon?: any;
},
): BaseT {
class EnumField extends (Base as any) {
static configuration =
Expand All @@ -109,6 +116,16 @@ function enumField<BaseT extends FieldDefConstructor>(
: ({
enum: { options: (config as any)?.options },
} as EnumConfiguration);
// defaultOptions is the static fallback for function-form options, which
// cannot be resolved at schema-generation time without a model instance.
// ai.ts reads it to emit a description hint ("Typical values: …") in the
// JSON schema so agents have guidance without being constrained to the list.
static defaultOptions =
typeof (config as any)?.options === 'function' &&
Array.isArray((config as any)?.defaultOptions) &&
(config as any).defaultOptions.length > 0
? (config as any).defaultOptions
: undefined;
Comment thread
Copilot marked this conversation as resolved.
static displayName =
(config as any)?.displayName ?? (Base as any).displayName;
static icon = (config as any)?.icon ?? (Base as any).icon;
Expand Down Expand Up @@ -223,7 +240,9 @@ function enumField<BaseT extends FieldDefConstructor>(
}
get selectedOption() {
let opts = this.options as any[];
let found = opts.find((o: any) => isEqual(o.value, (this.args.model as any)));
let found = opts.find((o: any) =>
isEqual(o.value, this.args.model as any),
);
return found === undefined ? undefined : found;
}
update = (opt: any) => {
Expand Down
116 changes: 116 additions & 0 deletions packages/host/tests/unit/ai-function-generation-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,122 @@ module('Unit | ai-function-generation-test', function (hooks) {
});
});

test(`surfaces defaultOptions as description hint (not enum constraint) for function-form enumField config`, async function (assert) {
let { field, contains, CardDef } = cardApi;
let { default: StringField } = string;
let { default: enumField } = enumModule;

const richOptions = [
{ value: 'critical', label: 'Critical' },
{ value: 'high', label: 'High' },
];
// function-form with rich defaultOptions — extracts .value
const RichDefaultField = enumField(StringField, {
options: function () {
return richOptions;
},
defaultOptions: richOptions,
});
// function-form with bare-primitive defaultOptions
const BareDefaultField = enumField(StringField, {
options: function () {
return ['a', 'b'];
},
defaultOptions: ['a', 'b'],
});
// function-form with empty defaultOptions — no enum emitted
const EmptyDefaultField = enumField(StringField, {
options: function () {
return [];
},
defaultOptions: [],
});

class TestCard extends CardDef {
@field richDefault = contains(RichDefaultField);
@field bareDefault = contains(BareDefaultField);
@field emptyDefault = contains(EmptyDefaultField);
}

let schema = generateJsonSchemaForCardType(TestCard, cardApi, mappings);
assert.deepEqual(schema, {
attributes: {
type: 'object',
properties: {
...cardDefAttributesProperties,
richDefault: {
type: 'string',
description: 'Typical values: "critical", "high"',
},
bareDefault: {
type: 'string',
description: 'Typical values: "a", "b"',
},
emptyDefault: { type: 'string' },
},
},
relationships: cardDefRelationships,
});
});

test(`ignores defaultOptions when options is a static array (emits hard enum, not hint)`, async function (assert) {
let { field, contains, CardDef } = cardApi;
let { default: StringField } = string;
let { default: enumField } = enumModule;

// defaultOptions is silently ignored when options is a static array —
// static options are always resolved at schema-generation time, so a hard
// enum constraint is correct and defaultOptions would be redundant.
const StaticWithDefault = enumField(StringField, {
options: ['x', 'y'],
defaultOptions: ['x', 'y'],
});

class TestCard extends CardDef {
@field staticWithDefault = contains(StaticWithDefault);
}

let schema = generateJsonSchemaForCardType(TestCard, cardApi, mappings);
assert.deepEqual(schema, {
attributes: {
type: 'object',
properties: {
...cardDefAttributesProperties,
staticWithDefault: { type: 'string', enum: ['x', 'y'] },
},
},
relationships: cardDefRelationships,
});
});

test(`omits enum for function-form enumField config with no defaultOptions`, async function (assert) {
let { field, contains, CardDef } = cardApi;
let { default: StringField } = string;
let { default: enumField } = enumModule;

const NoDefaultField = enumField(StringField, {
options: function () {
return ['x', 'y'];
},
});

class TestCard extends CardDef {
@field noDefault = contains(NoDefaultField);
}

let schema = generateJsonSchemaForCardType(TestCard, cardApi, mappings);
assert.deepEqual(schema, {
attributes: {
type: 'object',
properties: {
...cardDefAttributesProperties,
noDefault: { type: 'string' },
},
},
relationships: cardDefRelationships,
});
});

test(`generates a simple compliant schema for nested types`, async function (assert) {
let { field, contains, linksTo, linksToMany, CardDef, FieldDef } = cardApi;
let { default: StringField } = string;
Expand Down
52 changes: 40 additions & 12 deletions packages/runtime-common/helpers/ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -326,13 +326,30 @@ function getPrimitiveType(
* `option.value` for the former, the option itself for the latter.
*
* The function form of `configuration` (model-dependent options, e.g. a
* status list configured per project) is skipped: there is no model
* instance at schema-generation time to resolve it against.
* status list configured per project) cannot be resolved at schema-generation
* time. When `defaultOptions` is provided, it returns them with
* `isDynamic: true` so callers can use a non-constraining description hint
* instead of a hard `enum` — preserving the AI's ability to use custom
* project values that differ from the defaults.
*/
function getStaticEnumValues(
function getEnumValues(
def: typeof CardAPI.BaseDef,
): unknown[] | undefined {
): { values: unknown[]; isDynamic: boolean } | undefined {
let configuration = (def as { configuration?: unknown }).configuration;
if (typeof configuration === 'function') {
let defaultOptions = (def as { defaultOptions?: unknown[] }).defaultOptions;
if (Array.isArray(defaultOptions) && defaultOptions.length > 0) {
return {
values: defaultOptions.map((option) =>
option !== null && typeof option === 'object' && 'value' in option
? (option as { value: unknown }).value
: option,
),
isDynamic: true,
};
}
return undefined;
}
if (!configuration || typeof configuration !== 'object') {
return undefined;
}
Expand All @@ -341,11 +358,14 @@ function getStaticEnumValues(
if (!Array.isArray(options) || options.length === 0) {
return undefined;
}
return options.map((option) =>
option !== null && typeof option === 'object' && 'value' in option
? (option as { value: unknown }).value
: option,
);
return {
values: options.map((option) =>
option !== null && typeof option === 'object' && 'value' in option
? (option as { value: unknown }).value
: option,
),
isDynamic: false,
};
}

/**
Expand Down Expand Up @@ -375,9 +395,17 @@ function generateJsonSchemaForContainsFields(
// If we're looking at a primitive field we can get the schema
if (primitive in def) {
let schema = getPrimitiveType(def, mappings);
let enumValues = getStaticEnumValues(def);
if (schema && enumValues) {
return { ...schema, enum: enumValues } as AttributesSchema;
let enumResult = getEnumValues(def);
if (schema && enumResult) {
if (enumResult.isDynamic) {
// Options are resolved at runtime from the model instance, so
// defaultOptions only represents typical values — use a description
// hint rather than a hard enum constraint to avoid rejecting valid
// custom project values.
let hint = `Typical values: ${enumResult.values.map((v) => `"${v}"`).join(', ')}`;
return { ...schema, description: hint } as AttributesSchema;
Comment on lines +405 to +406
}
return { ...schema, enum: enumResult.values } as AttributesSchema;
}
return schema;
}
Expand Down
Loading
Loading