From 7a02b21d527605d6dcfecfcc4af97a39aff6cfc7 Mon Sep 17 00:00:00 2001 From: Guilhem Lemouel Date: Mon, 5 Oct 2026 08:07:30 -0700 Subject: [PATCH] fix(sessions): bind a press on the page to the call it started against A call stops being pending the instant Run is pressed, well before its job exists. The page went back to running on its own in that gap, so a second press there started a job the chat never heard about. A press now holds the call until the job lands or the call settles. A press also kept reading its runner across `processSecretArgs`: cancelling while a password was being minted switched the press to the page's own runner, which then started a job the reader had just cancelled. The runner is read once, when the press begins. A chat flow's message waits on that same round trip. It needs nothing from it, but a composer filled ahead of the inputs can be sent ahead of them, and the run would carry the inputs the proposal was meant to replace. Co-Authored-By: Claude Opus 5 (1M context) --- frontend/src/lib/components/RunForm.svelte | 7 +++++- .../lib/components/details/FlowDetail.svelte | 19 +++++++++----- .../sessions/sessionRuntime.svelte.ts | 25 ++++++++++++++++--- 3 files changed, 40 insertions(+), 11 deletions(-) diff --git a/frontend/src/lib/components/RunForm.svelte b/frontend/src/lib/components/RunForm.svelte index 5ddbf1cbc9..421bc9d41f 100644 --- a/frontend/src/lib/components/RunForm.svelte +++ b/frontend/src/lib/components/RunForm.svelte @@ -85,6 +85,11 @@ return } if (claimRun && !claimRun.claim()) return + // Captured for the whole press, not read again after the await below: a `runAction` that + // stands for something the press was started against — a chat tool call waiting on this + // form — is replaced by the page's own runner the moment that thing goes away, and a + // press that switched runners mid-flight would start a job nobody asked for. + const action = runAction let processedArgs: Record const { args: withDefaults, resetKeys } = enforceDisabledDefaults(args ?? {}, runnable?.schema) if (resetKeys.length > 0) { @@ -104,7 +109,7 @@ } } } - runAction( + action( overrideScheduledForStr === null ? undefined : (overrideScheduledForStr ?? scheduledForStr), processedArgs, invisible_to_owner, diff --git a/frontend/src/lib/components/details/FlowDetail.svelte b/frontend/src/lib/components/details/FlowDetail.svelte index b0546ce4a0..e665edd6d2 100644 --- a/frontend/src/lib/components/details/FlowDetail.svelte +++ b/frontend/src/lib/components/details/FlowDetail.svelte @@ -410,15 +410,22 @@ // 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. + // + // The message waits on the same round trip even though it needs nothing from it: + // a composer filled ahead of the inputs can be sent ahead of them, and that run + // would carry whatever the inputs were before — saved values or schema defaults, + // not the proposal the reader just agreed to send. const chat = flowChat processSecretArgs(inputs, flow?.schema as Schema | undefined, workspace) - .then((minted) => chat.applyInputs(minted)) + .then((minted) => { + chat.applyInputs(minted) + // 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 still says what was asked for. + if (typeof message === 'string' && message) chat.offerMessage(message) + }) .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 - // still says what was asked for. - if (typeof message === 'string' && message) flowChat.offerMessage(message) } else if (runForm) { seededCallId = pendingRun.toolCallId runForm.setArgs(pendingRun.args) diff --git a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts index dec410f30f..ed43b5bd3c 100644 --- a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts @@ -1,4 +1,4 @@ -import { SvelteMap } from 'svelte/reactivity' +import { SvelteMap, SvelteSet } from 'svelte/reactivity' import type { PendingRun } from '$lib/components/details/pendingRun' import { get } from 'svelte/store' import { base } from '$lib/base' @@ -577,6 +577,8 @@ function createRuntime(session: Session): SessionRuntime { // plain deployed page. const adoptedRuns = new SvelteMap }>() const adoptionKey = (kind: SessionTargetKind, path: string) => `${kind}:${path}` + /** Calls whose Run has been pressed on the page and whose job has not landed yet. */ + const heldPresses = new SvelteSet() /** The call waiting on this item's run form, for the viewer that renders it. Read live * rather than copied, so it disappears the moment the call settles however it settles. */ @@ -588,7 +590,15 @@ function createRuntime(session: Session): SessionRuntime { // a call can end is what covers the ways that reach no hook at all — a submit whose // job never started, because the server refused it or plan mode blocked it after the // arguments were already handed over. - if (!manager.isRunFormPending(adopted.toolCallId)) return undefined + // + // A press already handed over counts as waiting: the call stops being pending the + // instant Run is pressed, well before its job exists, and a page that went back to + // running on its own in that gap would start a second job for the same request on a + // second press. It ends when the job lands or the call settles, which both drop the + // adoption outright. + if (!manager.isRunFormPending(adopted.toolCallId) && !heldPresses.has(adopted.toolCallId)) { + return undefined + } return { toolCallId: adopted.toolCallId, args: adopted.args, @@ -600,9 +610,14 @@ function createRuntime(session: Session): SessionRuntime { sendUserToast(PLAN_MODE_MESSAGES.runFormRefused, true) return false } - return manager.beginRunFormSubmit(adopted.toolCallId) + if (!manager.beginRunFormSubmit(adopted.toolCallId)) return false + heldPresses.add(adopted.toolCallId) + return true + }, + release: () => { + heldPresses.delete(adopted.toolCallId) + manager.endRunFormSubmit(adopted.toolCallId) }, - release: () => manager.endRunFormSubmit(adopted.toolCallId), // False when the call is no longer waiting — a turn stopped out from under the page, // or a job that failed to start. The page says so rather than leaving Run dead. submit: (args) => manager.handleRunFormSubmit(adopted.toolCallId, args), @@ -620,6 +635,7 @@ function createRuntime(session: Session): SessionRuntime { /** Drop the adoption, leaving the page as the plain deployed page it also is. Called when * the call settles by any route: Run, Cancel, a stopped turn, the tab closing. */ function dropAdoption(toolCallId: string): void { + heldPresses.delete(toolCallId) for (const [key, adopted] of [...adoptedRuns]) { if (adopted.toolCallId === toolCallId) adoptedRuns.delete(key) } @@ -707,6 +723,7 @@ function createRuntime(session: Session): SessionRuntime { manager.showRunInPlaceOfForm = ({ toolCallId, jobId, workspace }) => { const runHref = `${base}/run/${jobId}?workspace=${workspace}` // Either surface the call was waiting on becomes the run it started, in place. + heldPresses.delete(toolCallId) for (const [key, adopted] of [...adoptedRuns]) { if (adopted.toolCallId !== toolCallId) continue adoptedRuns.delete(key)