From 5f2e2ad4bb83f326d7f63f1815976d1ac64c7c0c Mon Sep 17 00:00:00 2001 From: Burcu Noyan Date: Thu, 18 Jun 2026 17:43:11 -0400 Subject: [PATCH 1/3] add defaultOptions to enum fields and clean up issue-tracker option resolution --- packages/base/enum.gts | 26 ++- .../tests/unit/ai-function-generation-test.ts | 80 ++++++++++ packages/runtime-common/helpers/ai.ts | 11 ++ .../software-factory/realm/issue-tracker.gts | 118 ++++---------- .../software-factory/realm/kanban-config.gts | 150 ++++++------------ .../tests/runtime-schema.spec.ts | 30 +++- 6 files changed, 223 insertions(+), 192 deletions(-) diff --git a/packages/base/enum.gts b/packages/base/enum.gts index 0dcbbfe8ee..61f59e8f20 100644 --- a/packages/base/enum.gts +++ b/packages/base/enum.gts @@ -29,7 +29,9 @@ export function enumConfig( input: | EnumConfigurationInput | ({ options?: any[]; unsetLabel?: string } | undefined) - | ((self: Readonly) => + | (( + self: Readonly, + ) => | EnumConfiguration | { options?: any[]; unsetLabel?: string } | undefined), @@ -45,9 +47,9 @@ export function enumConfig( } if (typeof input === 'function') { - return (function (this: Readonly) { + return function (this: Readonly) { return normalize((input as any).call(this)); - }) as any; + } as any; } return normalize(input as any) as EnumConfiguration; } @@ -98,7 +100,12 @@ export function enumValues(model: object, fieldName: string): any[] { function enumField( 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 = @@ -109,6 +116,13 @@ function enumField( : ({ 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. + static defaultOptions = + typeof (config as any)?.options === 'function' && + Array.isArray((config as any)?.defaultOptions) + ? (config as any).defaultOptions + : undefined; static displayName = (config as any)?.displayName ?? (Base as any).displayName; static icon = (config as any)?.icon ?? (Base as any).icon; @@ -223,7 +237,9 @@ function enumField( } 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) => { diff --git a/packages/host/tests/unit/ai-function-generation-test.ts b/packages/host/tests/unit/ai-function-generation-test.ts index 443ae9d6d8..0e7fe2f026 100644 --- a/packages/host/tests/unit/ai-function-generation-test.ts +++ b/packages/host/tests/unit/ai-function-generation-test.ts @@ -187,6 +187,86 @@ module('Unit | ai-function-generation-test', function (hooks) { }); }); + test(`surfaces defaultOptions 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', enum: ['critical', 'high'] }, + bareDefault: { type: 'string', enum: ['a', 'b'] }, + emptyDefault: { type: 'string' }, + }, + }, + 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; diff --git a/packages/runtime-common/helpers/ai.ts b/packages/runtime-common/helpers/ai.ts index 8c71775686..6eaa8ade97 100644 --- a/packages/runtime-common/helpers/ai.ts +++ b/packages/runtime-common/helpers/ai.ts @@ -333,6 +333,17 @@ function getStaticEnumValues( def: typeof CardAPI.BaseDef, ): unknown[] | 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 defaultOptions.map((option) => + option !== null && typeof option === 'object' && 'value' in option + ? (option as { value: unknown }).value + : option, + ); + } + return undefined; + } if (!configuration || typeof configuration !== 'object') { return undefined; } diff --git a/packages/software-factory/realm/issue-tracker.gts b/packages/software-factory/realm/issue-tracker.gts index f1767e3977..1ae72a5c40 100644 --- a/packages/software-factory/realm/issue-tracker.gts +++ b/packages/software-factory/realm/issue-tracker.gts @@ -54,6 +54,7 @@ import { KanbanBoard } from './kanban-board'; import { KanbanColumnField } from './kanban-column'; import { KanbanBoardPlacement } from './kanban-board-placement'; import { + type Option, issueStatusOptions, issuePriorityOptions, issueTypeOptions, @@ -61,10 +62,10 @@ import { defaultColumns, findOptionColor, buildIssueOptionFields, - configuredIssueStatusOptions, - configuredIssueTypeOptions, - configuredIssuePriorityOptions, - configuredProjectStatusOptions, + getIssueStatusOptions, + getIssueTypeOptions, + getIssuePriorityOptions, + getProjectStatusOptions, IssueStatusField, IssueTypeField, IssuePriorityField, @@ -84,124 +85,75 @@ const issueCodeRef: ResolvedCodeRef = { }; // ── Issue ────────────────────────────────────────────────────────────────── -type IssueStatusProject = { - issueStatusOptions?: IssueOptionField[]; +type IssueWithProject = { + project?: { + issueStatusOptions?: IssueOptionField[]; + issueTypeOptions?: IssueOptionField[]; + issuePriorityOptions?: IssueOptionField[]; + } | null; }; -type IssueStatusIssue = { - project?: IssueStatusProject | null; -}; - -function getProjectIssueStatusOptions( - project: IssueStatusProject | null | undefined, -) { - return configuredIssueStatusOptions(project); -} - function getIssueStatusOption( - issue: IssueStatusIssue | null | undefined, + issue: IssueWithProject | null | undefined, value: string | null | undefined, -) { - if (!value) { - return undefined; - } - - return getProjectIssueStatusOptions(issue?.project).find( - (option) => option.value === value, - ); +): Option | undefined { + if (!value) return undefined; + return getIssueStatusOptions(issue?.project).find((o) => o.value === value); } function getIssueStatusLabel( - issue: IssueStatusIssue | null | undefined, + issue: IssueWithProject | null | undefined, value: string | null | undefined, ) { return getIssueStatusOption(issue, value)?.label ?? value ?? 'Backlog'; } function getIssueStatusColor( - issue: IssueStatusIssue | null | undefined, + issue: IssueWithProject | null | undefined, value: string | null | undefined, ) { return getIssueStatusOption(issue, value)?.color; } -type IssueTypeProject = { - issueTypeOptions?: IssueOptionField[]; -}; - -type IssueTypeIssue = { - project?: IssueTypeProject | null; -}; - -function getProjectIssueTypeOptions( - project: IssueTypeProject | null | undefined, -) { - return configuredIssueTypeOptions(project); -} - function getIssueTypeOption( - issue: IssueTypeIssue | null | undefined, + issue: IssueWithProject | null | undefined, value: string | null | undefined, -) { - if (!value) { - return undefined; - } - - return getProjectIssueTypeOptions(issue?.project).find( - (option) => option.value === value, - ); +): Option | undefined { + if (!value) return undefined; + return getIssueTypeOptions(issue?.project).find((o) => o.value === value); } function getIssueTypeLabel( - issue: IssueTypeIssue | null | undefined, + issue: IssueWithProject | null | undefined, value: string | null | undefined, ) { return getIssueTypeOption(issue, value)?.label ?? value ?? undefined; } function getIssueTypeColor( - issue: IssueTypeIssue | null | undefined, + issue: IssueWithProject | null | undefined, value: string | null | undefined, ) { return getIssueTypeOption(issue, value)?.color; } -type IssuePriorityProject = { - issuePriorityOptions?: IssueOptionField[]; -}; - -type IssuePriorityIssue = { - project?: IssuePriorityProject | null; -}; - -function getProjectIssuePriorityOptions( - project: IssuePriorityProject | null | undefined, -) { - return configuredIssuePriorityOptions(project); -} - function getIssuePriorityOption( - issue: IssuePriorityIssue | null | undefined, + issue: IssueWithProject | null | undefined, value: string | null | undefined, -) { - if (!value) { - return undefined; - } - - return getProjectIssuePriorityOptions(issue?.project).find( - (option) => option.value === value, - ); +): Option | undefined { + if (!value) return undefined; + return getIssuePriorityOptions(issue?.project).find((o) => o.value === value); } function getIssuePriorityLabel( - issue: IssuePriorityIssue | null | undefined, + issue: IssueWithProject | null | undefined, value: string | null | undefined, ) { return getIssuePriorityOption(issue, value)?.label ?? value ?? undefined; } function getIssuePriorityColor( - issue: IssuePriorityIssue | null | undefined, + issue: IssueWithProject | null | undefined, value: string | null | undefined, ) { return getIssuePriorityOption(issue, value)?.color; @@ -1419,7 +1371,7 @@ class ProjectIsolated extends Component { get statusColor(): string | undefined { return findOptionColor( - configuredProjectStatusOptions(this.args.model), + getProjectStatusOptions(this.args.model), this.args.model?.projectStatus ?? 'planning', ); } @@ -1977,7 +1929,7 @@ class ProjectEdit extends Component { get statusColor(): string | undefined { return findOptionColor( - configuredProjectStatusOptions(this.args.model), + getProjectStatusOptions(this.args.model), this.args.model?.projectStatus ?? 'planning', ); } @@ -2500,7 +2452,7 @@ export class Project extends CardDef { static fitted = class Fitted extends Component { get statusColor(): string | undefined { return findOptionColor( - configuredProjectStatusOptions(this.args.model), + getProjectStatusOptions(this.args.model), this.args.model?.projectStatus ?? 'planning', ); } @@ -2729,10 +2681,10 @@ class IssueTrackerIsolated extends Component { get columns(): KanbanColumnConfig[] { let options = this.activeGroupBy === 'priority' - ? configuredIssuePriorityOptions(this.args.model?.project) + ? getIssuePriorityOptions(this.args.model?.project) : this.activeGroupBy === 'issueType' - ? configuredIssueTypeOptions(this.args.model?.project) - : getProjectIssueStatusOptions(this.args.model?.project); + ? getIssueTypeOptions(this.args.model?.project) + : getIssueStatusOptions(this.args.model?.project); let stored = this.args.model.columns ?? []; let optionKeys = new Set(options.map((o) => o.value)); diff --git a/packages/software-factory/realm/kanban-config.gts b/packages/software-factory/realm/kanban-config.gts index 1aa993a710..b00932a27e 100644 --- a/packages/software-factory/realm/kanban-config.gts +++ b/packages/software-factory/realm/kanban-config.gts @@ -96,28 +96,17 @@ type IssueOptionArray = Array<{ color?: string | null; }>; -type IssueStatusOptionSource = { - issueStatusOptions?: IssueOptionArray; -}; - -type IssueStatusOwner = { project?: IssueStatusOptionSource | null }; - -function hasProject( - owner: IssueStatusOwner | IssueStatusOptionSource, -): owner is IssueStatusOwner { +function hasProject( + owner: S | { project?: S | null }, +): owner is { project?: S | null } { return 'project' in owner; } -export function configuredIssueStatusOptions( - owner: IssueStatusOwner | IssueStatusOptionSource | null | undefined, +function resolveOptions( + optionArray: IssueOptionArray | null | undefined, + defaults: Option[], ): Option[] { - let statusOptions = owner - ? hasProject(owner) - ? owner.project?.issueStatusOptions - : owner.issueStatusOptions - : undefined; - - let configured = (statusOptions ?? []) + let configured = (optionArray ?? []) .map( (option): Option => ({ value: option.value ?? '', @@ -126,126 +115,91 @@ export function configuredIssueStatusOptions( }), ) .filter((option) => option.value && option.label); - - return configured.length ? configured : issueStatusOptions; + return configured.length ? configured : defaults; } -type IssueTypeOptionSource = { - issueTypeOptions?: IssueOptionArray; -}; - -type IssueTypeOwner = { project?: IssueTypeOptionSource | null }; +type IssueStatusOptionSource = { issueStatusOptions?: IssueOptionArray }; +type IssueStatusOwner = { project?: IssueStatusOptionSource | null }; -function hasProjectForType( - owner: IssueTypeOwner | IssueTypeOptionSource, -): owner is IssueTypeOwner { - return 'project' in owner; +export function getIssueStatusOptions( + owner: IssueStatusOwner | IssueStatusOptionSource | null | undefined, +): Option[] { + let options = owner + ? hasProject(owner) + ? owner.project?.issueStatusOptions + : owner.issueStatusOptions + : undefined; + return resolveOptions(options, issueStatusOptions); } -export function configuredIssueTypeOptions( +type IssueTypeOptionSource = { issueTypeOptions?: IssueOptionArray }; +type IssueTypeOwner = { project?: IssueTypeOptionSource | null }; + +export function getIssueTypeOptions( owner: IssueTypeOwner | IssueTypeOptionSource | null | undefined, ): Option[] { - let typeOptions = owner - ? hasProjectForType(owner) + let options = owner + ? hasProject(owner) ? owner.project?.issueTypeOptions : owner.issueTypeOptions : undefined; + return resolveOptions(options, issueTypeOptions); +} - let configured = (typeOptions ?? []) - .map( - (option): Option => ({ - value: option.value ?? '', - label: option.label ?? '', - color: option.color ?? undefined, - }), - ) - .filter((option) => option.value && option.label); +type IssuePriorityOptionSource = { issuePriorityOptions?: IssueOptionArray }; +type IssuePriorityOwner = { project?: IssuePriorityOptionSource | null }; + +export function getIssuePriorityOptions( + owner: IssuePriorityOwner | IssuePriorityOptionSource | null | undefined, +): Option[] { + let options = owner + ? hasProject(owner) + ? owner.project?.issuePriorityOptions + : owner.issuePriorityOptions + : undefined; + return resolveOptions(options, issuePriorityOptions); +} - return configured.length ? configured : issueTypeOptions; +export function getProjectStatusOptions( + owner: { projectStatusOptions?: IssueOptionArray | null } | null | undefined, +): Option[] { + return resolveOptions(owner?.projectStatusOptions, projectStatusOptions); } export const IssueStatusField = enumField(StringField, { options: function (this: { project?: { issueStatusOptions?: IssueOptionField[] } | null; }) { - return configuredIssueStatusOptions(this); + return getIssueStatusOptions(this); }, + defaultOptions: issueStatusOptions, }); export const IssueTypeField = enumField(StringField, { options: function (this: { project?: { issueTypeOptions?: IssueOptionField[] } | null; }) { - return configuredIssueTypeOptions(this); + return getIssueTypeOptions(this); }, + defaultOptions: issueTypeOptions, }); -type IssuePriorityOptionSource = { - issuePriorityOptions?: IssueOptionArray; -}; - -type IssuePriorityOwner = { project?: IssuePriorityOptionSource | null }; - -function hasProjectForPriority( - owner: IssuePriorityOwner | IssuePriorityOptionSource, -): owner is IssuePriorityOwner { - return 'project' in owner; -} - -export function configuredIssuePriorityOptions( - owner: IssuePriorityOwner | IssuePriorityOptionSource | null | undefined, -): Option[] { - let priorityOptions = owner - ? hasProjectForPriority(owner) - ? owner.project?.issuePriorityOptions - : owner.issuePriorityOptions - : undefined; - - let configured = (priorityOptions ?? []) - .map( - (option): Option => ({ - value: option.value ?? '', - label: option.label ?? '', - color: option.color ?? undefined, - }), - ) - .filter((option) => option.value && option.label); - - return configured.length ? configured : issuePriorityOptions; -} - export const IssuePriorityField = enumField(StringField, { options: function (this: { project?: { issuePriorityOptions?: IssueOptionField[] } | null; }) { - return configuredIssuePriorityOptions(this); + return getIssuePriorityOptions(this); }, + defaultOptions: issuePriorityOptions, }); -export function configuredProjectStatusOptions( - owner: { projectStatusOptions?: IssueOptionArray | null } | null | undefined, -): Option[] { - let statusOptions = owner?.projectStatusOptions; - - let configured = (statusOptions ?? []) - .map( - (option): Option => ({ - value: option.value ?? '', - label: option.label ?? '', - color: option.color ?? undefined, - }), - ) - .filter((option) => option.value && option.label); - - return configured.length ? configured : projectStatusOptions; -} - export const ProjectStatusField = enumField(StringField, { options: function (this: { projectStatusOptions?: IssueOptionField[] | null; }) { - return configuredProjectStatusOptions(this); + return getProjectStatusOptions(this); }, + defaultOptions: projectStatusOptions, }); export const GroupByField = enumField(StringField, { diff --git a/packages/software-factory/tests/runtime-schema.spec.ts b/packages/software-factory/tests/runtime-schema.spec.ts index e41bbac010..e70ac77027 100644 --- a/packages/software-factory/tests/runtime-schema.spec.ts +++ b/packages/software-factory/tests/runtime-schema.spec.ts @@ -102,14 +102,32 @@ test('fetches Issue schema with enum fields', async ({ realm }) => { expect(attrs.properties).toHaveProperty('summary'); expect(attrs.properties).toHaveProperty('status'); expect(attrs.properties).toHaveProperty('priority'); - expect(attrs.properties).toHaveProperty('issueType'); - // issueType, priority, and status are all configured per project - // (function-form configuration), so none have a static enum in the schema. - expect(attrs.properties.issueType.enum).toBeUndefined(); - expect(attrs.properties.priority.enum).toBeUndefined(); - expect(attrs.properties.status.enum).toBeUndefined(); + // enumField-backed fields with static options carry their allowed + // values; agents consume these instead of grepping field sources. + expect(attrs.properties.issueType.enum).toEqual([ + 'bootstrap', + 'feature', + 'adjustment', + 'bug', + 'task', + 'research', + 'infrastructure', + ]); + expect(attrs.properties.priority.enum).toEqual([ + 'critical', + 'high', + 'medium', + 'low', + ]); + expect(attrs.properties.status.enum).toEqual([ + 'backlog', + 'in_progress', + 'blocked', + 'review', + 'done', + ]); } finally { cleanup(); } From 03f91aa5acfcdd4ca11ab0d32925256ccb97b661 Mon Sep 17 00:00:00 2001 From: Burcu Noyan Date: Fri, 19 Jun 2026 10:34:04 -0400 Subject: [PATCH 2/3] do not constain dynamic enum values to defaultOptions --- packages/base/enum.gts | 2 + .../tests/unit/ai-function-generation-test.ts | 42 +++++++++++++-- packages/runtime-common/helpers/ai.ts | 51 ++++++++++++------- .../tests/runtime-schema.spec.ts | 35 ++++--------- 4 files changed, 86 insertions(+), 44 deletions(-) diff --git a/packages/base/enum.gts b/packages/base/enum.gts index 61f59e8f20..a07007f927 100644 --- a/packages/base/enum.gts +++ b/packages/base/enum.gts @@ -118,6 +118,8 @@ function enumField( } 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) diff --git a/packages/host/tests/unit/ai-function-generation-test.ts b/packages/host/tests/unit/ai-function-generation-test.ts index 0e7fe2f026..3f89325bd4 100644 --- a/packages/host/tests/unit/ai-function-generation-test.ts +++ b/packages/host/tests/unit/ai-function-generation-test.ts @@ -187,7 +187,7 @@ module('Unit | ai-function-generation-test', function (hooks) { }); }); - test(`surfaces defaultOptions for function-form enumField config`, async function (assert) { + 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; @@ -230,8 +230,14 @@ module('Unit | ai-function-generation-test', function (hooks) { type: 'object', properties: { ...cardDefAttributesProperties, - richDefault: { type: 'string', enum: ['critical', 'high'] }, - bareDefault: { type: 'string', enum: ['a', 'b'] }, + richDefault: { + type: 'string', + description: 'Typical values: "critical", "high"', + }, + bareDefault: { + type: 'string', + description: 'Typical values: "a", "b"', + }, emptyDefault: { type: 'string' }, }, }, @@ -239,6 +245,36 @@ module('Unit | ai-function-generation-test', function (hooks) { }); }); + 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; diff --git a/packages/runtime-common/helpers/ai.ts b/packages/runtime-common/helpers/ai.ts index 6eaa8ade97..88a2d7e4c1 100644 --- a/packages/runtime-common/helpers/ai.ts +++ b/packages/runtime-common/helpers/ai.ts @@ -326,21 +326,27 @@ 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 defaultOptions.map((option) => - option !== null && typeof option === 'object' && 'value' in option - ? (option as { value: unknown }).value - : option, - ); + return { + values: defaultOptions.map((option) => + option !== null && typeof option === 'object' && 'value' in option + ? (option as { value: unknown }).value + : option, + ), + isDynamic: true, + }; } return undefined; } @@ -352,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, + }; } /** @@ -386,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; + } + return { ...schema, enum: enumResult.values } as AttributesSchema; } return schema; } diff --git a/packages/software-factory/tests/runtime-schema.spec.ts b/packages/software-factory/tests/runtime-schema.spec.ts index e70ac77027..31abdd50b8 100644 --- a/packages/software-factory/tests/runtime-schema.spec.ts +++ b/packages/software-factory/tests/runtime-schema.spec.ts @@ -104,30 +104,17 @@ test('fetches Issue schema with enum fields', async ({ realm }) => { expect(attrs.properties).toHaveProperty('priority'); expect(attrs.properties).toHaveProperty('issueType'); - // enumField-backed fields with static options carry their allowed - // values; agents consume these instead of grepping field sources. - expect(attrs.properties.issueType.enum).toEqual([ - 'bootstrap', - 'feature', - 'adjustment', - 'bug', - 'task', - 'research', - 'infrastructure', - ]); - expect(attrs.properties.priority.enum).toEqual([ - 'critical', - 'high', - 'medium', - 'low', - ]); - expect(attrs.properties.status.enum).toEqual([ - 'backlog', - 'in_progress', - 'blocked', - 'review', - 'done', - ]); + // Function-form enum fields emit a description hint (not a hard enum + // constraint) so agents can still use custom project-defined values. + expect(attrs.properties.issueType.description).toBe( + 'Typical values: "bootstrap", "feature", "adjustment", "bug", "task", "research", "infrastructure"', + ); + expect(attrs.properties.priority.description).toBe( + 'Typical values: "critical", "high", "medium", "low"', + ); + expect(attrs.properties.status.description).toBe( + 'Typical values: "backlog", "in_progress", "blocked", "review", "done"', + ); } finally { cleanup(); } From 04c175b08e0eb3c8410d65f046ccc834d0bad399 Mon Sep 17 00:00:00 2001 From: Burcu Noyan Date: Fri, 19 Jun 2026 16:30:37 -0400 Subject: [PATCH 3/3] Check if array is empty Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- packages/base/enum.gts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/base/enum.gts b/packages/base/enum.gts index a07007f927..eec3503f54 100644 --- a/packages/base/enum.gts +++ b/packages/base/enum.gts @@ -122,7 +122,8 @@ function enumField( // 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) + Array.isArray((config as any)?.defaultOptions) && + (config as any).defaultOptions.length > 0 ? (config as any).defaultOptions : undefined; static displayName =