diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index b8664c1221..a84bc1b31f 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -1793,6 +1793,13 @@ export class AIChatManager { toolId: string, _form: RunFormDisplay ): Promise | undefined> => { + // The tool reads the deployed schema before it asks, so a stop during that read + // drains the callbacks and settles the card before this runs. Installing one then + // would park the turn on a form the settled card no longer renders, leaving nothing + // able to resolve it. The controller is per-turn, so a later turn still opens. + if (this.abortController?.signal.aborted) { + return Promise.resolve(undefined) + } return new Promise((resolve) => { this.runFormCallbacks.set(toolId, resolve) }) diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts index 937afc0677..26e26452e0 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts @@ -261,6 +261,30 @@ describe('AIChatManager run form', () => { expect(manager.displayMessages[0].isLoading).toBe(true) }) + // The tool reads the deployed schema before it asks for arguments. A stop during that + // read drains the callbacks and settles the card, so a waiter installed afterwards was + // one no rendered form could resolve: the turn stayed loading until a second stop. + it('installs no run-form waiter once the turn is stopped', async () => { + const manager = new AIChatManager() + // The turn the tool is running under; cancel aborts it. + manager.abortController = new AbortController() + manager.displayMessages = [ + { + role: 'tool', + tool_call_id: 'call_late', + content: 'Executing...', + isLoading: true + } + ] + + manager.cancel() + + await expect( + manager.requestRunArgs('call_late', { path: 'f/a/b', schema: {}, args: {} }) + ).resolves.toBeUndefined() + expect(manager.isRunFormPending('call_late')).toBe(false) + }) + // Stop ends the turn, not the job: the deployed script is already running with all // its side effects, so the transcript must not record it as cancelled. it('does not mark a started run cancelled when the turn is stopped', () => { diff --git a/frontend/src/lib/components/job_args.test.ts b/frontend/src/lib/components/job_args.test.ts index e96eaf4e8d..f05cde7c3d 100644 --- a/frontend/src/lib/components/job_args.test.ts +++ b/frontend/src/lib/components/job_args.test.ts @@ -228,6 +228,37 @@ describe('conformArgsToSchema', () => { }) }) + // A number input bound to a string renders blank and `validateInput` range-checks only + // an actual number, so the field passed as filled: the user approved an empty box and + // the job received the string. + it('drops a primitive that contradicts its declared scalar type', () => { + const schema = { + properties: { + count: { type: 'number' }, + retries: { type: 'integer' }, + name: { type: 'string' }, + dry_run: { type: 'boolean' } + } + } + expect( + conformArgsToSchema( + { count: 'not-a-number', retries: '3', name: 'ada', dry_run: 'false' }, + schema + ) + ).toMatchObject({ + args: { name: 'ada' }, + dropped: { undeclared: [], unshowable: ['count', 'retries', 'dry_run'] } + }) + // A dyn-select slot is `type: 'object'` holding whatever the helper returns, so + // only a declared scalar type constrains a primitive. + expect( + conformArgsToSchema( + { tenant: 'alpha' }, + { properties: { tenant: { type: 'object', format: 'dynselect-list_tenants' } } } + ) + ).toMatchObject({ args: { tenant: 'alpha' }, dropped: { unshowable: [] } }) + }) + // A list element is bound to a widget the same way a top-level argument is, so an // object in a scalar slot renders as [object Object] and the run carries it verbatim. it('filters array elements whose schema declares no properties', () => { diff --git a/frontend/src/lib/components/job_args.ts b/frontend/src/lib/components/job_args.ts index bde36db3ff..6b8beeb07e 100644 --- a/frontend/src/lib/components/job_args.ts +++ b/frontend/src/lib/components/job_args.ts @@ -118,6 +118,15 @@ const declaresDynMultiselect = (prop: any) => const declaresProperties = (prop: any) => prop?.properties != null && Object.keys(prop.properties).length > 0 +/** + * Whether a primitive fits a declared scalar type. Each scalar widget binds one JS type and + * shows nothing else: a string in a number input renders blank, and `ArgInput.validateInput` + * range-checks only an actual number, so the field passes as filled. The user would approve + * an empty box over a value only the job ever sees. + */ +const fitsScalarType = (value: any, type: string): boolean => + type === 'integer' ? typeof value === 'number' : typeof value === type + /** * Whether `prop` declares a slot the form can show `value` in. A value that fits nowhere * is one the user would approve unseen: it matches no level below, so every filter falls @@ -127,7 +136,8 @@ const declaresProperties = (prop: any) => function fitsDeclaredShape(value: any, prop: any): boolean { if (value == null) return true if (declaresDynMultiselect(prop)) return Array.isArray(value) - if (typeof value !== 'object') return true + if (typeof value !== 'object') + return !SCALAR_TYPES.has(prop?.type) || fitsScalarType(value, prop.type) if (SCALAR_TYPES.has(prop?.type)) return false const isArray = Array.isArray(value) // Declared nested structure, never the declared `type`: a dyn-multiselect argument is