diff --git a/frontend/src/lib/components/copilot/chat/RunArgsFormDisplay.svelte b/frontend/src/lib/components/copilot/chat/RunArgsFormDisplay.svelte
index afa9df0c25..6072b6e341 100644
--- a/frontend/src/lib/components/copilot/chat/RunArgsFormDisplay.svelte
+++ b/frontend/src/lib/components/copilot/chat/RunArgsFormDisplay.svelte
@@ -37,6 +37,10 @@
// it would discard those edits.
const draft = untrack(() => aiChatManager.runFormDraft(toolCallId, runForm))
+ const runnableKind = $derived(runForm.runnableKind ?? 'script')
+ const staleFormToast = () =>
+ sendUserToast(`This run form is no longer active — ask again to run the ${runnableKind}.`, true)
+
const properties = $derived(draft.schema?.properties ?? {})
const hasArgs = $derived(Object.keys(properties).length > 0)
@@ -71,7 +75,7 @@
// manager that opened it, so submitting would mint an ephemeral secret variable
// per click and still run nothing.
if (!aiChatManager.isRunFormPending(toolCallId)) {
- sendUserToast('This run form is no longer active — ask again to run the script.', true)
+ staleFormToast()
return
}
// Ahead of processSecretArgs, which writes ephemeral variables to the workspace: the
@@ -102,7 +106,7 @@
// then the ephemeral variables exist — say so rather than leaving a dead button.
if (!aiChatManager.handleRunFormSubmit(toolCallId, processed)) {
aiChatManager.endRunFormSubmit(toolCallId)
- sendUserToast('This run form is no longer active — ask again to run the script.', true)
+ staleFormToast()
}
}
@@ -151,7 +155,7 @@
? runForm.code && runForm.lang
? { source: 'inline', code: runForm.code, lang: runForm.lang }
: undefined
- : { source: 'deployed', path: runForm.path, runnable_kind: 'script' }}
+ : { source: 'deployed', path: runForm.path, runnable_kind: runnableKind }}
disabled={planMode}
{workspace}
prettifyHeader
@@ -159,7 +163,7 @@
bind:args={draft.args}
/>
{:else}
-
This script takes no arguments.
+ This {runnableKind} takes no arguments.
{/if}
@@ -196,7 +200,7 @@
{/if}
{#if runForm.resetKeys?.length}
- Disabled by this script, so it will run with its default:
+ Disabled by this {runnableKind}, so it will run with its default:
{runForm.resetKeys.join(', ')}
{/if}
diff --git a/frontend/src/lib/components/copilot/chat/RunScriptCard.svelte b/frontend/src/lib/components/copilot/chat/RunScriptCard.svelte
index 4d19ce2ecf..edd1704bb2 100644
--- a/frontend/src/lib/components/copilot/chat/RunScriptCard.svelte
+++ b/frontend/src/lib/components/copilot/chat/RunScriptCard.svelte
@@ -27,6 +27,7 @@
let { message }: Props = $props()
const runForm = $derived(message.runForm!)
+ const runnableKind = $derived(runForm.runnableKind ?? 'script')
// The loop is parked on the form and nothing has run yet: the card is the form.
const pending = $derived(isActiveRunForm(message))
@@ -103,8 +104,8 @@
)
const cancelReason = $derived(
ran
- ? 'This run was cancelled while the script was running.'
- : 'This run was cancelled before the script started.'
+ ? `This run was cancelled while the ${runnableKind} was running.`
+ : `This run was cancelled before the ${runnableKind} started.`
)
// Streaming opens the tab early: the result is already arriving, and one that appeared
// only at the end would hide the thing the user is waiting to read.
@@ -296,7 +297,7 @@
tab it already opened. The row's only control, as on every other tool call. -->
{#snippet previewChip()}
aiChatManager.cancelJob(chatJob.jobId)}
>
Cancel
diff --git a/frontend/src/lib/components/copilot/chat/global/core.test.ts b/frontend/src/lib/components/copilot/chat/global/core.test.ts
index 1132c4c77f..bcf7bfb6e2 100644
--- a/frontend/src/lib/components/copilot/chat/global/core.test.ts
+++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts
@@ -4750,12 +4750,17 @@ describe('global AI tools', () => {
)
})
+ // The form offers the arguments the flow declares and no others, so a fixture flow that
+ // takes one has to say so — as a real flow does, since nothing else could render a field.
+ const FLOW_NAME_SCHEMA = { type: 'object', properties: { name: { type: 'string' } } }
+
it('test_run_flow previews draft flow content by path', async () => {
const modules = [{ id: 'start', value: { type: 'identity' } }]
await callGlobalTool('write_flow', {
path: 'f/flows/draft-test',
summary: 'Draft test flow',
- modules: JSON.stringify(modules)
+ modules: JSON.stringify(modules),
+ schema: JSON.stringify(FLOW_NAME_SCHEMA)
})
await withCompletedTestJob(() =>
@@ -4782,7 +4787,7 @@ describe('global AI tools', () => {
path: 'f/flows/deployed-test',
summary: 'Deployed test flow',
value: { modules },
- schema: {}
+ schema: FLOW_NAME_SCHEMA
} as any)
await withCompletedTestJob(() =>
@@ -4816,7 +4821,7 @@ describe('global AI tools', () => {
path: 'u/admin/live_flow',
summary: 'Live flow',
value: { modules: [{ id: 'live_step', value: { type: 'identity' } }] },
- schema: {},
+ schema: FLOW_NAME_SCHEMA,
edited_by: '',
edited_at: '',
archived: false,
@@ -4839,12 +4844,14 @@ describe('global AI tools', () => {
path: 'u/admin/live_flow',
args: { name: 'Ada' }
},
- toolCallbacks,
+ { ...toolCallbacks, requestRunArgs: async () => ({ name: 'Grace' }) },
{ testActiveFlow }
)
)
- expect(testActiveFlow).toHaveBeenCalledWith('u/admin/live_flow_storage', { name: 'Ada' })
+ // What the form submitted, not what the model proposed: the editor runs the flow, but
+ // the arguments are the user's.
+ expect(testActiveFlow).toHaveBeenCalledWith('u/admin/live_flow_storage', { name: 'Grace' })
expect(FlowService.getFlowByPath).not.toHaveBeenCalled()
expect(JobService.runFlowPreview).not.toHaveBeenCalled()
expect(result).toContain('Result (SUCCESS)')
@@ -4858,7 +4865,7 @@ describe('global AI tools', () => {
path: 'u/admin/live_flow_fallback',
summary: 'Live flow fallback',
value: { modules: [{ id: 'fallback_step', value: { type: 'identity' } }] },
- schema: {},
+ schema: FLOW_NAME_SCHEMA,
edited_by: '',
edited_at: '',
archived: false,
@@ -4898,6 +4905,110 @@ describe('global AI tools', () => {
})
})
+ // A flow reaches its run form the way a script does: opened on the flow's own input schema,
+ // with the dynamic-option picker the schema carries, and the run takes what came back.
+ it('test_run_flow opens the form on the flow schema and runs what it submitted', async () => {
+ const modules = [{ id: 'start', value: { type: 'identity' } }]
+ await callGlobalTool('write_flow', {
+ path: 'f/flows/formed-flow',
+ summary: 'Formed flow',
+ modules: JSON.stringify(modules),
+ schema: JSON.stringify({
+ ...FLOW_NAME_SCHEMA,
+ 'x-windmill-dyn-select-code': 'export function names() { return ["Ada"] }',
+ 'x-windmill-dyn-select-lang': 'bun'
+ })
+ })
+
+ let form: any
+ await withCompletedTestJob(() =>
+ callGlobalTool(
+ 'test_run_flow',
+ { path: 'f/flows/formed-flow', args: { name: 'Ada' } },
+ {
+ ...toolCallbacks,
+ requestRunArgs: async (_toolId, f) => {
+ form = f
+ return { name: 'Grace' }
+ }
+ }
+ )
+ )
+
+ expect(form.args).toEqual({ name: 'Ada' })
+ expect(form.runnableKind).toBe('flow')
+ expect(form.schema?.properties).toEqual(FLOW_NAME_SCHEMA.properties)
+ // The flow's own dynselect script, which the schema carries rather than a step.
+ expect({ code: form.code, lang: form.lang }).toEqual({
+ code: 'export function names() { return ["Ada"] }',
+ lang: 'bun'
+ })
+ expect(JobService.runFlowPreview).toHaveBeenCalledWith({
+ workspace: WORKSPACE,
+ requestBody: {
+ path: 'f/flows/formed-flow',
+ value: { modules },
+ args: { name: 'Grace' }
+ }
+ })
+ })
+
+ // The form waits as long as the user does, so which editor is on screen is only known when
+ // they press Run — checking it when the card appeared would drive an editor they have since
+ // moved away from.
+ it('test_run_flow re-checks the editor on screen when the form is submitted', async () => {
+ const modules = [{ id: 'moved_step', value: { type: 'identity' } }]
+ seedBackendDraft(
+ 'flow',
+ 'u/admin/moved_flow',
+ {
+ path: 'u/admin/moved_flow',
+ summary: 'Moved flow',
+ value: { modules },
+ schema: FLOW_NAME_SCHEMA,
+ edited_by: '',
+ edited_at: '',
+ archived: false,
+ extra_perms: {}
+ },
+ { workspace: WORKSPACE }
+ )
+ UserDraft.setLiveEditorDraft({
+ workspace: WORKSPACE,
+ itemKind: 'flow',
+ storagePath: 'u/admin/moved_flow',
+ effectivePath: 'u/admin/moved_flow'
+ })
+ const testActiveFlow = vi.fn(async () => 'job-live-flow')
+
+ await withCompletedTestJob(() =>
+ callGlobalTool(
+ 'test_run_flow',
+ { path: 'u/admin/moved_flow', args: { name: 'Ada' } },
+ {
+ ...toolCallbacks,
+ requestRunArgs: async (_toolId, form) => {
+ // The preview panel moves to another flow while the form sits open.
+ UserDraft.setLiveEditorDraft({
+ workspace: WORKSPACE,
+ itemKind: 'flow',
+ storagePath: 'u/admin/other_flow',
+ effectivePath: 'u/admin/other_flow'
+ })
+ return form.args
+ }
+ },
+ { testActiveFlow }
+ )
+ )
+
+ expect(testActiveFlow).not.toHaveBeenCalled()
+ expect(JobService.runFlowPreview).toHaveBeenCalledWith({
+ workspace: WORKSPACE,
+ requestBody: { path: 'u/admin/moved_flow', value: { modules }, args: { name: 'Ada' } }
+ })
+ })
+
// The flow may be open in a session tab that isn't the one on screen: driving its editor
// would paint the run into a tab the user is not looking at.
it('test_run_flow previews rather than driving an editor the user is not looking at', async () => {
@@ -4908,7 +5019,7 @@ describe('global AI tools', () => {
path: 'u/admin/background_flow',
summary: 'Background flow',
value: { modules: [{ id: 'background_step', value: { type: 'identity' } }] },
- schema: {},
+ schema: FLOW_NAME_SCHEMA,
edited_by: '',
edited_at: '',
archived: false,
diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts
index 868d0e1728..0fe23361e7 100644
--- a/frontend/src/lib/components/copilot/chat/global/core.ts
+++ b/frontend/src/lib/components/copilot/chat/global/core.ts
@@ -929,7 +929,7 @@ const testRunFlowSchema = z.object({
const testRunFlowToolDef = createToolDef(
testRunFlowSchema,
'test_run_flow',
- 'Execute a preview-style test run of a flow by path, preferring draft content when it exists.',
+ 'Execute a preview-style test run of a flow by path, preferring draft content when it exists. The user gets an argument form prefilled with `args` and may edit or dismiss it before it runs, so fill in every argument you can infer. For a secret argument prefer `$var:` naming an existing workspace variable; a literal is minted into a short-lived secret before the run, but stays in this call.',
{ strict: false }
)
@@ -1348,7 +1348,7 @@ ${pipelineBullet}
: ' Pass items (":" entries naming the items you changed) so the review is scoped to them — omitting items preselects every pending change in the workspace'
}, or mode ("draft" or "fork") to force which comparison is shown. Prefer offering this review page over calling deploy_workspace_item directly when several items changed.
- For a Windmill operation no other tool covers (workers, queue state, a run's args, ...), use search_api_endpoints to find a REST endpoint, then call_api_get for reads or call_api_endpoint for mutations (the user is asked to confirm those). Always prefer a dedicated tool when one exists; endpoints for authoring or deleting scripts, flows, apps, schedules, resources, or variables are not available through the API catalog tools — use the draft tools and delete_workspace_item instead.
-- Default to test_run_script, test_run_flow, or test_run_step for any run request, an existing script included; they prefer drafts and need no deployment. Use run_script only when the user names the deployed version ("the deployed X", "in production", "for real") — a bare "run X" is not that. For run_script, read the item with read_workspace_item version: "deployed" first so the arguments match the deployed schema, and fill in every one you can infer. runFlowByPath from the API catalog runs a deployed flow without a form: only for a flow the user asked to run deployed.
+- Default to test_run_script, test_run_flow, or test_run_step for any run request, an existing script included; they prefer drafts and need no deployment. Use run_script only when the user names the deployed version ("the deployed X", "in production", "for real") — a bare "run X" is not that. For run_script, read the item with read_workspace_item version: "deployed" first so the arguments match the deployed schema, and fill in every one you can infer. test_run_script, run_script and test_run_flow all show the user an argument form prefilled with what you sent, so fill in every argument you can infer rather than asking for it in chat. runFlowByPath from the API catalog is the exception — it runs a deployed flow with no form at all: only for a flow the user asked to run deployed.
- When a required decision is ambiguous, use askUserQuestion with two to ten clear proposed answer strings instead of guessing. The user can also type a custom answer when none of the proposed answers fit. Set multiSelect: true only when the answers can genuinely co-apply and the user may pick several (not mutually exclusive).
- When the user asks you to remember a lasting preference, always/never do something, or change/stop a behavior going forward, call update_user_instructions to persist it. It edits only the USER INSTRUCTIONS block (not WORKSPACE INSTRUCTIONS). Keep each instruction concise; do not use it for one-off requests scoped to the current task.
- Keep context targeted.${
@@ -3696,8 +3696,10 @@ export const globalTools: Tool<{}>[] = [
const parsed = testRunFlowSchema.parse(ctx.args)
return testRunFlowByPath(parsed, ctx)
},
- requiresConfirmation: true,
- confirmationMessage: (args) => `Run a test of ${pathLeaf(args?.path, 'the flow')}`,
+ // No requiresConfirmation, for the reason test_run_script carries.
+ bypassedByAutoAccept: true,
+ streamingLabel: 'Preparing the test form...',
+ confirmationMessage: 'Run a test of a flow',
queuedLabel: (args) => `Test ${args?.path ?? 'the flow'}`,
showDetails: true,
autoCollapseDetails: false
@@ -5501,8 +5503,8 @@ async function testRunScriptByPath(
/** The "do not call again" half is load-bearing: without it the model re-proposes the
* call, which re-opens the form the user just dismissed, and Stop becomes their only
* way out. */
-const runFormCancelled = (toolName: string) =>
- `The user cancelled the run form. The script did NOT run. Do not call ${toolName} again unless the user asks for it.`
+const runFormCancelled = (toolName: string, noun: string) =>
+ `The user cancelled the run form. The ${noun} did NOT run. Do not call ${toolName} again unless the user asks for it.`
/** The model only needs to see what the user changed, and nothing bounds an object or
* array argument the form let them paste into. */
@@ -5550,7 +5552,7 @@ async function runThroughForm(spec: FormRunSpec, ctx: WriteDraftCtx): Promise,
ctx: WriteDraftCtx
): Promise {
- const { workspace, toolId, toolCallbacks } = ctx
- const testArgs = normalizeTestRunArgs(args.args)
- const testActiveFlow = liveFlowTestHookFromCtx(ctx, args.path)
+ const { workspace } = ctx
+ // The schema must be in hand before the form is built, and the value rides along from the
+ // same read so the fields and the previewed flow are one version. With an editor open on
+ // this path this reads its in-memory cell rather than the network.
+ const flow = await loadFlowDraftValue(args.path, workspace)
+ const schema = (flow.flow.schema as Record | null | undefined) ?? {}
- if (testActiveFlow) {
- return executeTestRun({
- jobStarter: async () => {
- const jobId = await testActiveFlow(testArgs)
+ return runThroughForm(
+ {
+ path: args.path,
+ schema,
+ summary: flow.summary,
+ kind: 'test',
+ // A flow's dynamic-option pickers are one script stored on the schema itself, which is
+ // where the editor's own test form reads them from (FlowPreviewContent).
+ code: schema['x-windmill-dyn-select-code'],
+ lang: schema['x-windmill-dyn-select-lang'],
+ // Never "deployed": a test run previews the draft, so a line sending the model to the
+ // deployed schema would name the wrong version.
+ schemaNoun: 'flow',
+ toolName: 'test_run_flow',
+ proposed: args.args,
+ startMessage: `Starting flow test run for "${args.path}"...`,
+ contextName: 'flow',
+ // The model is told to test and iterate, so the bypass posture answers the form.
+ autoAcceptable: true,
+ background: args.background,
+ detachAfterMs: waitSecondsToDetachMs(args.wait_seconds),
+ startJob: async (submitted) => {
+ // An open editor runs its own in-memory flow and paints the run in its graph.
+ // Resolved here rather than before the form: the form waits as long as the user
+ // does, and the editor on screen when they press Run is the one it belongs in.
+ const jobId = await liveFlowTestHookFromCtx(ctx, args.path)?.(submitted)
if (jobId) {
return jobId
}
-
- const flow = await loadFlowDraftValue(args.path, workspace)
return JobService.runFlowPreview({
workspace,
requestBody: {
path: args.path,
value: flowDraftValueForPreview(flow.flow),
- args: testArgs
+ args: submitted
}
})
- },
- workspace,
- toolCallbacks,
- toolId,
- startMessage: `Starting flow test run for "${args.path}"...`,
- contextName: 'flow',
- background: args.background,
- detachAfterMs: waitSecondsToDetachMs(args.wait_seconds),
- label: args.path
- })
- }
-
- const flow = await loadFlowDraftValue(args.path, workspace)
-
- return executeTestRun({
- jobStarter: () =>
- JobService.runFlowPreview({
- workspace,
- requestBody: {
- path: args.path,
- value: flowDraftValueForPreview(flow.flow),
- args: testArgs
- }
- }),
- workspace,
- toolCallbacks,
- toolId,
- startMessage: `Starting flow test run for "${args.path}"...`,
- contextName: 'flow',
- background: args.background,
- detachAfterMs: waitSecondsToDetachMs(args.wait_seconds),
- label: args.path
- })
+ }
+ },
+ ctx
+ )
}
async function testRunFlowStepByPath(
diff --git a/frontend/src/lib/components/copilot/chat/shared.ts b/frontend/src/lib/components/copilot/chat/shared.ts
index bc429d9d15..adb0f63be4 100644
--- a/frontend/src/lib/components/copilot/chat/shared.ts
+++ b/frontend/src/lib/components/copilot/chat/shared.ts
@@ -561,14 +561,18 @@ export type RunFormDisplay = {
/** What the run is, in the card's own words: a deployed script run, or a preview of the
* draft being written. Only the tense of the row's label turns on it. */
kind?: 'run' | 'test'
+ /** What is being run, for the noun the card says it in.
+ * Absent on cards recorded before flows had a form, which were all scripts. */
+ runnableKind?: 'script' | 'flow'
/** Of whatever version is about to run: the deployed script, or the draft a test
* previews. Only the rendered form reads it, so it is dropped once one of the flags
* below unmounts that form: kept, every settled card would carry a copy of the schema
* — password and file defaults included — in history forever. */
schema?: Record
- /** The draft a test run previews, for the `dynselect-` helper only — a deployed helper
- * would answer for the wrong version. Set on a test run alone, and dropped with the
- * schema once the form unmounts, so no settled card carries a copy of the code. */
+ /** The script the `dynselect-` helper runs: the draft a script test run previews, since a
+ * deployed helper would answer for the wrong version, or the one a flow schema carries on
+ * itself. Dropped with the schema once the form unmounts, so no settled card keeps a copy
+ * of the code. */
code?: string
lang?: ScriptLang
/** Prefill only: the card's `parameters` records what the job started with. */
diff --git a/frontend/src/lib/components/sessions/RunFormPreviewSlot.svelte b/frontend/src/lib/components/sessions/RunFormPreviewSlot.svelte
index 336c03fb1a..3384086da7 100644
--- a/frontend/src/lib/components/sessions/RunFormPreviewSlot.svelte
+++ b/frontend/src/lib/components/sessions/RunFormPreviewSlot.svelte
@@ -1,6 +1,6 @@