fix: drop a run-form argument that contradicts its declared scalar type, and settle a form stopped mid-load

This commit is contained in:
AlexRV12
2026-09-03 16:30:09 +02:00
parent bd65c9dccf
commit 752da04ab6
4 changed files with 73 additions and 1 deletions
@@ -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', () => {
+11 -1
View File
@@ -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