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) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-10-05 08:07:30 -07:00
co-authored by Claude Opus 5
parent 0ac9b56ba3
commit 7a02b21d52
3 changed files with 40 additions and 11 deletions
+6 -1
View File
@@ -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<string, any>
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,
@@ -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)
@@ -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<string, { toolCallId: string; args: Record<string, any> }>()
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<string>()
/** 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)