mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-06 08:01:35 +00:00
fix: drop a run-form argument that contradicts its declared scalar type, and settle a form stopped mid-load
This commit is contained in:
@@ -1793,6 +1793,13 @@ export class AIChatManager {
|
||||
toolId: string,
|
||||
_form: RunFormDisplay
|
||||
): Promise<Record<string, any> | 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)
|
||||
})
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user