fix(sessions): hand the call back when the page cannot honour it

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) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-10-05 07:51:38 -07:00
co-authored by Claude Opus 5
parent 72ea7d416a
commit 0ac9b56ba3
8 changed files with 103 additions and 10 deletions
@@ -84,6 +84,7 @@
if (blockedByUnparseable) {
return
}
if (claimRun && !claimRun.claim()) return
let processedArgs: Record<string, any>
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,
@@ -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}`
@@ -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.
@@ -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}
@@ -419,10 +419,6 @@
let args: Record<string, any> | 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}
@@ -15,6 +15,14 @@ export type PendingRun = {
toolCallId: string
/** What the model proposed, already narrowed to the deployed schema by the tool. */
args: Record<string, any>
/** 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. */
@@ -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
@@ -26,6 +26,8 @@ import {
// the response type still need an explicit cast).
type SavedScript = Omit<Script & UserDraftOverlay, 'draft'> & { draft?: NewScript }
type SavedFlow = Omit<Flow & UserDraftOverlay, 'draft'> & { draft?: Flow }
import { PLAN_MODE_MESSAGES } from '$lib/components/copilot/chat/planModeMessages'
import { sendUserToast } from '$lib/toast'
import type { HiddenRunnable } from '$lib/components/apps/types'
import { type RawAppData, DEFAULT_DATA } from '$lib/components/raw_apps/dataTableRefUtils'
import { userWorkspaces, workspaceStore } from '$lib/stores'
@@ -581,9 +583,26 @@ function createRuntime(session: Session): SessionRuntime {
function pendingRunFor(kind: SessionTargetKind, path: string): PendingRun | undefined {
const adopted = adoptedRuns.get(adoptionKey(kind, path))
if (!adopted) return undefined
// The map says which call chose this page; whether that call is still waiting is the
// manager's to answer. Asking it here rather than dropping the adoption from each way
// 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
return {
toolCallId: adopted.toolCallId,
args: adopted.args,
// Both guards belong ahead of the form's own `processSecretArgs`, which writes
// ephemeral variables to the workspace: plan mode can be switched on while the form
// sits here, and a second press would mint a second set of them.
claim: () => {
if (manager.planModeActive) {
sendUserToast(PLAN_MODE_MESSAGES.runFormRefused, true)
return false
}
return manager.beginRunFormSubmit(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),
@@ -680,8 +699,8 @@ function createRuntime(session: Session): SessionRuntime {
manager.closeRunForm = (toolCallId) => {
// The page outlives the call it adopted — it is still the item's deployed page — so
// settling only drops the adoption, and the form goes back to running on its own.
// Reached however the call ends, a job that failed to start included, which is what
// keeps a settled call from leaving an inert Run button behind.
// Reached on a cancel; a call that ends any other way stops being pending, which
// `pendingRunFor` reads, so the entry left here is inert either way.
dropAdoption(toolCallId)
previewTabs.closeRunForm(toolCallId)
}
@@ -706,9 +725,25 @@ function createRuntime(session: Session): SessionRuntime {
// to `tabs` through it, and the runtime is not inside an effect root to push from.
// An adopted deployed page counts: its form is this call's form, so the card must not
// mount a second one there either.
// The card folds its form away only while something else is showing it. For an adopted
// page that means an unpinned viewer on the item: a tab the reader pinned to a version
// hands the call back (`ItemViewerView`), and a card that kept pointing at it would leave
// the call confirmable nowhere.
manager.isRunFormInPreview = (toolCallId) =>
previewTabs.tabs.some((t) => parseRunFormRoute(t.url)?.toolCallId === toolCallId) ||
[...adoptedRuns.values()].some((a) => a.toolCallId === toolCallId)
[...adoptedRuns].some(([key, a]) => {
if (a.toolCallId !== toolCallId) return false
const [kind, path] = [key.slice(0, key.indexOf(':')), key.slice(key.indexOf(':') + 1)]
return previewTabs.tabs.some((t) => {
const slot = resolvePreviewTab(t.url)
return (
slot.kind === 'viewer' &&
slot.viewerKind === kind &&
slot.path === path &&
!t.url.includes('version=')
)
})
})
manager.openArtifact = (id, name, version) => {
previewTabs.open({ type: 'artifact', id, name, version })