From 0ac9b56ba339f86b9f9ee03d7c682c8fddbc4afd Mon Sep 17 00:00:00 2001 From: Guilhem Lemouel Date: Mon, 5 Oct 2026 07:51:38 -0700 Subject: [PATCH] fix(sessions): hand the call back when the page cannot honour it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A page carries an adopted call only while it can run what the call asked for. Picking a version broke that silently: the tool starts its job by path, so Run on a page pinned to an older version ran the deployed one instead — the page said v1 and the job returned v3. The page now hands the call back when it is on a version, and the card takes its form back, which is where the call can still be confirmed as proposed. Whether the call is still waiting is the manager's answer, not the adoption map's. Reading it live covers the ends that reach no hook: a submit whose job never started, because the server refused it or plan mode blocked it after the arguments were handed over. Those left the page with a dead Run and no way out but the X. The page's Run also has to clear what the card's clears before the form mints a password into a workspace variable — plan mode, and a second press. Co-Authored-By: Claude Opus 5 (1M context) --- frontend/src/lib/components/RunForm.svelte | 8 ++++ .../copilot/chat/RunScriptCard.svelte | 4 +- .../copilot/chat/global/core.test.ts | 31 ++++++++++++++ .../lib/components/details/FlowDetail.svelte | 9 +++- .../components/details/ScriptDetail.svelte | 5 +-- .../src/lib/components/details/pendingRun.ts | 8 ++++ .../components/sessions/ItemViewerView.svelte | 7 +++- .../sessions/sessionRuntime.svelte.ts | 41 +++++++++++++++++-- 8 files changed, 103 insertions(+), 10 deletions(-) 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