diff --git a/frontend/src/lib/components/RunForm.svelte b/frontend/src/lib/components/RunForm.svelte index 2ae75f3c47..5ddbf1cbc9 100644 --- a/frontend/src/lib/components/RunForm.svelte +++ b/frontend/src/lib/components/RunForm.svelte @@ -84,6 +84,7 @@ if (blockedByUnparseable) { return } + if (claimRun && !claimRun.claim()) return let processedArgs: Record const { args: withDefaults, resetKeys } = enforceDisabledDefaults(args ?? {}, runnable?.schema) if (resetKeys.length > 0) { @@ -92,6 +93,7 @@ try { processedArgs = await processSecretArgs(withDefaults, runnable?.schema, $operatingWorkspace) } catch (e) { + claimRun?.release() sendUserToast('Failed to process sensitive args: ' + e, true) return } @@ -133,6 +135,11 @@ invisible_to_owner: boolean | undefined, overrideTag: string | undefined ) => void + /** Asked before anything is written, for a `runAction` whose own guards must beat + * `processSecretArgs` to the workspace: false abandons the press. `release` is called + * if the press then ends before `runAction`, so a guard that counts presses can stop + * counting this one. */ + claimRun?: { claim: () => boolean; release: () => void } buttonText?: string schedulable?: boolean detailed?: boolean @@ -160,6 +167,7 @@ let { runnable, runAction, + claimRun = undefined, buttonText = 'Run', schedulable = true, detailed = true, diff --git a/frontend/src/lib/components/copilot/chat/RunScriptCard.svelte b/frontend/src/lib/components/copilot/chat/RunScriptCard.svelte index ed831eff25..2421142fe0 100644 --- a/frontend/src/lib/components/copilot/chat/RunScriptCard.svelte +++ b/frontend/src/lib/components/copilot/chat/RunScriptCard.svelte @@ -413,7 +413,9 @@ previewTarget === 'form' ? `Open this form in the preview panel: ${path}` : previewTarget === 'deployed' - ? `Open the deployed ${runnableKind === 'flow' ? 'flow' : 'script'} and its run form: ${path}` + ? // Not "and its run form": a chat-enabled flow's deployed page is a conversation, + // and nothing here can tell one from an ordinary flow. + `Open the deployed ${runnableKind === 'flow' ? 'flow' : 'script'} and confirm this run there: ${path}` : aiChatManager.openRunInPreview ? `Open this run in the preview panel: ${path || runnableName}` : `Open this run in a new tab: ${path || runnableName}` 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 63bfa6e1b2..70e85fc671 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts @@ -5294,6 +5294,37 @@ describe('global AI tools', () => { expect(refused).toContain('cannot show a run form') }) + // A page that settled the call by running the item its own way — a chat flow, whose page is + // a conversation with no Run button — is not a refusal. Told it was cancelled, the model + // reports that nothing ran while the run is in that conversation. + it('run_flow reports the run when a page settled the call instead of refusing it', async () => { + vi.mocked(FlowService.getFlowByPath).mockResolvedValue({ + path: 'f/flows/chat', + schema: { properties: { user_message: { type: 'string' } } } + } as any) + + const statuses: any[] = [] + const reason = 'The user ran "f/flows/chat" themselves by sending the message.' + const answer = await callGlobalTool( + 'run_flow', + { path: 'f/flows/chat', args: { user_message: 'hi' } }, + { + ...toolCallbacks, + setToolStatus: (_toolId: string, status: any) => statuses.push(status), + requestRunArgs: async () => undefined, + runFormDeclineReason: () => reason + } + ) + + expect(JobService.runFlowByPath).not.toHaveBeenCalled() + expect(answer).toContain(reason) + const settled = statuses.at(-1) + expect(settled.content).toBe(reason) + // Both: the call did start nothing, and the card must still not call that a cancellation. + expect(settled.declinedByUser).toBe(true) + expect(settled.ranElsewhere).toBe(true) + }) + // The posture answers wherever it is set, form or no form: what it answers is consent, and a // host without one has nothing left to ask. A secret still becomes a reference first, which // is the only thing the missing form would have done. diff --git a/frontend/src/lib/components/details/FlowDetail.svelte b/frontend/src/lib/components/details/FlowDetail.svelte index 472032d909..b0546ce4a0 100644 --- a/frontend/src/lib/components/details/FlowDetail.svelte +++ b/frontend/src/lib/components/details/FlowDetail.svelte @@ -407,7 +407,13 @@ // without the rest would run on saved values or schema defaults — a different run // from the one proposed. These land whether or not the message does. const { user_message: message, ...inputs } = pendingRun.args ?? {} - flowChat.applyInputs(inputs) + // Minted before they are applied, not only before the run: the composer's inputs + // are saved to localStorage as soon as the reader touches any of them, so a + // `password` the model proposed as a literal would be written there in the clear. + const chat = flowChat + processSecretArgs(inputs, flow?.schema as Schema | undefined, workspace) + .then((minted) => chat.applyInputs(minted)) + .catch((e) => sendUserToast('Failed to process sensitive args: ' + e, true)) // A composer already holding a draft declines, and the message is then dropped // rather than held: injecting it whenever the reader happens to clear their draft // would put words in the box long after they were proposed. The strip above it @@ -960,6 +966,7 @@ bind:isValid runnable={flow} {runAction} + claimRun={pendingRun} bind:args schedulable={!pendingRun} bind:this={runForm} diff --git a/frontend/src/lib/components/details/ScriptDetail.svelte b/frontend/src/lib/components/details/ScriptDetail.svelte index 74705b2fb7..054f340660 100644 --- a/frontend/src/lib/components/details/ScriptDetail.svelte +++ b/frontend/src/lib/components/details/ScriptDetail.svelte @@ -419,10 +419,6 @@ let args: Record | undefined = $state(undefined) - // Seeded once the form exists, and again whenever another request arrives for a page - // already open — latched on the request rather than on "seeded once", which would leave - // the previous turn's values on screen. The arguments were narrowed against this script's - // deployed schema before they got here, by the tool that opened the page. // Run hands the arguments to the waiting call instead of starting a job: the tool that // parked on this form starts one itself when it resumes. const runAction = $derived( @@ -1136,6 +1132,7 @@ bind:isValid runnable={script} {runAction} + claimRun={pendingRun} bind:args schedulable={!pendingRun} bind:this={runForm} diff --git a/frontend/src/lib/components/details/pendingRun.ts b/frontend/src/lib/components/details/pendingRun.ts index 41bac3a58d..8e8f9423dc 100644 --- a/frontend/src/lib/components/details/pendingRun.ts +++ b/frontend/src/lib/components/details/pendingRun.ts @@ -15,6 +15,14 @@ export type PendingRun = { toolCallId: string /** What the model proposed, already narrowed to the deployed schema by the tool. */ args: Record + /** Take the call, before the form mints a `password` field into an ephemeral workspace + * variable. False means this press runs nothing: plan mode is on, or a press is already + * in flight. Both have to be answered here rather than in {@link submit}, which the form + * only reaches after it has written those variables. */ + claim: () => boolean + /** Give the call back when a claimed press ends without reaching {@link submit}, so the + * next one is not refused as a double press. */ + release: () => void /** Hand the form's current arguments to the waiting call. False when it is no longer * waiting — a stopped turn, or a job that failed to start — so the page can say so * instead of leaving Run looking live. */ diff --git a/frontend/src/lib/components/sessions/ItemViewerView.svelte b/frontend/src/lib/components/sessions/ItemViewerView.svelte index e60f7f1865..d1aae6d6b7 100644 --- a/frontend/src/lib/components/sessions/ItemViewerView.svelte +++ b/frontend/src/lib/components/sessions/ItemViewerView.svelte @@ -69,7 +69,12 @@ // The call waiting on this item's run form, when the reader chose to confirm it here // rather than on the card. Read live from the runtime, so it disappears the moment the // call settles — by Run, by Cancel, by a stopped turn or by this tab closing. - const pendingRun = $derived(runtime.pendingRunFor(kind, path)) + // + // Never on a version: the tool starts its job by path, so it would run the deployed + // version while this page shows the one the reader pinned. Picking a version hands the + // page back to itself — its own Run executes what is on screen — and the card takes its + // form back, which is the only place the call can still be confirmed as proposed. + const pendingRun = $derived(version ? undefined : runtime.pendingRunFor(kind, path)) /** Refetch the deployed item. The host calls this for the reload signals that reach a * tab from outside it; nothing about becoming visible triggers it, so a form the reader diff --git a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts index c76a08bd8b..dec410f30f 100644 --- a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts @@ -26,6 +26,8 @@ import { // the response type still need an explicit cast). type SavedScript = Omit